Security · Lesson 03
Securing APIs with Input Validation
Your API's attack surface is every byte it accepts. Client-controlled input is the most reliable path into a system, which is why injection attacks top vulnerability charts decade after decade. This lesson shows how to close that door.
By the end you'll be able to
- Explain why "never trust the client" is the foundational rule of input handling.
- Distinguish allow-list from deny-list validation and state which is stronger.
- Rewrite a concatenated SQL query as a parameterized query and explain why it prevents injection.
The foundational rule: never trust the client
An API must treat every byte from outside its trust boundary as potentially hostile — even if the sender is your own mobile app running your own JavaScript. Why? Because an attacker can bypass the client entirely and send raw HTTP requests using curl, Burp Suite, or a custom script. The client-side validation you wrote, the JavaScript that "prevents" bad input, the mobile app that "only" shows valid options — none of it exists from the server's perspective. The only validation that counts is validation on the server.
Think of it like a bank teller window. The rope maze and the numbered ticket system are conveniences for honest customers. A determined bad actor ignores both and walks straight to the counter. The teller — the server — must check ID regardless of how the person arrived.
Allow-list vs deny-list
There are two philosophies for deciding what input is acceptable:
| Approach | How it works | Strength |
|---|---|---|
| Allow-list (whitelist) | Define exactly what is valid; reject everything else | Strong — unknown inputs fail closed |
| Deny-list (blacklist) | Define what is forbidden; pass everything else | Weak — attackers find gaps in the list |
Allow-listing is almost always the right default. Specify the type, length, format, and allowed range for every field. If an input doesn't match, reject it with a 400. Do not try to sanitise it — sanitisation logic is where bypasses live.
# Allow-list examples — validate field by field
# Good: exact type + range check
age: integer, 0 ≤ age ≤ 150
# Good: constrained string with regex
username: string, matches /^[a-zA-Z0-9_]{3,32}$/"
# Good: enum for known values
status: one of ["active", "inactive", "pending"]
# Bad: blacklisting — an attacker encodes the blocked chars
comment: reject if contains "'" or "--" # ← bypassed with Unicode lookalikes
Injection attacks — the anatomy
Injection happens when unsanitised user input is interpreted as code or commands by a downstream system. The three most common flavours in API contexts:
| Type | Downstream system targeted | What the attacker injects |
|---|---|---|
| SQL injection | Relational database | SQL keywords / clauses that modify the intended query |
| NoSQL injection | MongoDB, Redis, etc. | Operator keys like $where, $gt to bypass filters |
| Command injection | OS shell (when an API runs system commands) | Shell metacharacters: ; | ` $() |
SQL injection — vulnerable vs fixed
Here is a login endpoint that concatenates user input into a SQL string, followed by how an attacker exploits it, followed by the correct parameterized fix:
// VULNERABLE — string concatenation (Node.js + mysql2 example)
const query = "SELECT * FROM users WHERE username='"
+ req.body.username
"' AND password='"
+ req.body.password + "'";
await db.query(query); // ← executes attacker-controlled SQL string directly
Parameterized queries (also called prepared statements) work because the SQL structure is fixed when it is sent to the database. The user-supplied values fill placeholders after the database has already parsed the query. No matter what string the attacker puts in, the database can never be tricked into treating it as SQL syntax.
Validate type, size, and range — everywhere
Injection is the dramatic example, but most real-world input bugs are quieter. A JSON body that contains a 50 MB string where you expected 200 characters can exhaust memory. A negative integer where you expected a positive quantity can underflow account balances. An unexpected field in a POST body can silently overwrite a protected attribute (mass-assignment — see below). Validate all of these at the boundary:
# OpenAPI 3.1 allow-list inline — validate in the schema, before any code runs
requestBody:
content:
application/json:
schema:
type: object
required: [name, quantity]
additionalProperties: false # rejects unknown fields
properties:
name:
type: string
minLength: 1
maxLength: 120
pattern: '^[a-zA-Z0-9 \-]+$'
quantity:
type: integer
minimum: 1
maximum: 1000
Mass-assignment
Mass-assignment is what happens when you automatically bind all incoming JSON fields to a model object without checking which fields the caller is allowed to set. A simple example: your User model has an is_admin field. Your update endpoint is:
// UNSAFE — blindly bind all request fields to the user object
PATCH /v1/users/42
Body: { "email": "alice@example.com", "is_admin": true }
// The server does: user.update(request.body) ← promotes caller to admin
// SAFE — explicit allow-list of writable fields
const allowed = ['email', 'display_name'];
const updates = pick(req.body, allowed); // ignores is_admin entirely
user.update(updates);
This is classified as OWASP API3:2023 — Broken Object Property Level Authorization. The vulnerability is subtle: the attacker does not need to break authentication or guess a path. They simply copy a field they observed in a read response (role, is_admin, verified, plan) and include it in a write request. Because the server processes every field it receives without an explicit allowlist of writable fields, the privileged field is silently applied. Use an explicit allowlist of accepted fields for every write endpoint — never use ORM mass-assignment helpers (Rails's update(params), Django's __dict__.update(), Node's Object.assign(model, req.body)) without first filtering to only the fields the caller is permitted to change.
// ── BEFORE (unsafe) — any field in the body is applied to the DB record ──────
// Framework: Express + Sequelize (Node.js)
app.patch('/v1/users/:id', requireAuth, async (req, res) => {
const user = await User.findByPk(req.params.id);
await user.update(req.body); // ← UNSAFE: maps ALL body fields to DB columns
res.json(user);
// Attacker body: {"email":"x@x.com","role":"admin","is_admin":true,"plan":"enterprise"}
// Every field is written — caller has promoted themselves
});
// ── AFTER (safe) — only explicitly allowed fields can be written ──────────────
const USER_WRITABLE_FIELDS = ['email', 'display_name', 'avatar_url', 'bio'];
// role, is_admin, plan, verified are intentionally absent from this list
app.patch('/v1/users/:id', requireAuth, async (req, res) => {
const user = await User.findByPk(req.params.id);
const safeUpdates = pick(req.body, USER_WRITABLE_FIELDS); // drop unknown fields
await user.update(safeUpdates); // ← SAFE: only whitelisted columns touched
res.json(user);
// Attacker body: {"email":"x@x.com","role":"admin"} → role is silently ignored
});
Output encoding
Input validation stops bad data entering. Output encoding prevents stored bad data from being executed when it leaves. If you store a user-supplied string in a database and later return it inside an HTML response without encoding it, a stored XSS attack is possible. For JSON APIs the risk is lower (JSON is data, not markup), but any endpoint that renders HTML or serves files must encode output for the target context (HTML entities for HTML, URL encoding for URLs, etc.).
When asked "how do you prevent SQL injection?" a junior answer is "sanitize inputs." A senior answer is "use parameterized queries — sanitisation is error-prone because it operates on the attack vector itself. Parameterization removes the attack surface entirely: user input is always data, never parsed as SQL." Then add: "validate on allow-list before the query, and use a least-privilege DB user so a compromised query can only read what it needs."
Deny-list filtering as a primary defence. Blocking ' and -- seems like it prevents SQL injection. Attackers use URL encoding (%27), Unicode look-alikes (ʼ), double-encoding, and comment variants to bypass it. Every new bypass found in the wild means your deny-list needs updating — an arms race you will lose. Use parameterized queries instead; they eliminate the category, not individual characters.
Do validate at the API boundary using an allow-list schema (OpenAPI, Joi, Pydantic, Zod — pick your ecosystem's tool), use parameterized queries for every database call, and set additionalProperties: false in your request schema to block mass-assignment by default. Don't rely on client-side validation, deny-list filters, or "trusted" fields passed in query parameters.
Under the hood: how it actually works
String concatenation creates injection because the database's SQL parser sees user input as part of the SQL grammar — it has no way to distinguish "code" from "data" when they arrive as one string. The parser treats the entire concatenated string as a SQL statement, so any SQL syntax embedded in the user's value becomes part of the query's structure.
# Step 1 — The vulnerable server code
# Developer intends this query:
SELECT * FROM users WHERE id = '<user_input>'
# Server code (Node.js):
const query = "SELECT * FROM users WHERE id = '" + req.body.id + "'";
# Step 2 — Attacker sends id = ' OR '1'='1
# Resulting query string sent to DB:
SELECT * FROM users WHERE id = '' OR '1'='1'
# DB parser sees:
# WHERE clause: (id = '') OR ('1' = '1')
# '1'='1' is always TRUE
# → condition is TRUE for every row
# → returns all users
# Step 3 — More dangerous: UNION-based extraction
# Attacker sends id = ' UNION SELECT username, password, null FROM users --
# Resulting query:
SELECT * FROM users WHERE id = '' UNION SELECT username, password, null FROM users --'
# → dumps the entire users table including password hashes
# Step 4 — Even more dangerous: stacked queries (database-dependent)
# Attacker sends id = '; DROP TABLE orders; --
# Resulting query on MySQL with multi-statement enabled:
SELECT * FROM users WHERE id = ''; DROP TABLE orders; --'
# → first query returns empty, second drops the table
| Attacker input (id field) | Resulting WHERE clause | Effect |
|---|---|---|
' OR '1'='1 |
WHERE id='' OR '1'='1' |
Returns all rows — auth bypass |
' OR 1=1 LIMIT 1 -- |
WHERE id='' OR 1=1 LIMIT 1 |
Returns first row regardless of id — log in as first user |
' UNION SELECT table_name,null FROM information_schema.tables -- |
WHERE id='' UNION SELECT table_name... |
Reveals all table names in the database |
'; UPDATE users SET is_admin=1 WHERE id=42; -- |
Two statements: SELECT + UPDATE | Promotes user 42 to admin (if multi-statement allowed) |
Parameterized queries close this gap because the SQL driver sends the query template and the parameter values as separate protocol messages to the database. The database parses the SQL structure first, with the ? placeholder already understood as "a data slot, not SQL". When the value arrives, the parser has already finished — there is no grammar left to inject into.
How to debug & inspect it
Here is how to probe your own API for SQL injection and how to read the signs when something is wrong.
| Symptom | Cause | Fix |
|---|---|---|
| API returns a raw database error message (SQL syntax, ORA-, SQLSTATE) | Input containing SQL metacharacters (quotes, dashes) breaks string concatenation | Switch to parameterized queries; catch DB exceptions and return a generic 500 without the DB message |
Different response body/size for id=1 AND 1=1 vs id=1 AND 1=2 |
Boolean-based blind SQL injection — query structure is injectable | Parameterized queries; also validate that id is an integer before it reaches any query |
| Response contains data from unexpected tables or columns | UNION-based injection — attacker appended a second SELECT | Parameterized queries; use least-privilege DB user that can only SELECT from allowed tables |
| Rows that shouldn't be visible appear for certain users | BOLA + missing WHERE owner_id clause (different from injection but similar symptom) | Add AND owner_id = ? to every query scoped to a user; review all queries that use user-supplied IDs |
Log shows '; DROP TABLE or UNION SELECT in request parameters |
Active SQL injection attempt in logs | Block/rate-limit the IP; audit whether any request with those payloads returned a 200; confirm parameterized queries are in place |
- Search the codebase for string concatenation into queries:
grep -rn "f\"SELECT\|+ \"SELECT\|+ 'SELECT"— any hit is a candidate vulnerability. - Confirm every DB call uses
?/%s/$1placeholders, not f-strings or.format(). - Send a single quote (
') to every parameter that reaches a DB query — a 500 with a DB error means concatenation. - Verify the DB user has only SELECT/INSERT/UPDATE on the tables it needs — no DROP, no GRANT, no access to system tables.
- Ensure DB error messages are caught and replaced with a generic 500 response before reaching the client.
By the numbers
Let's quantify the compute cost, CPU core scaling, and vulnerability math associated with input schema validation and Regular Expression Denial of Service (ReDoS). Consider an API Gateway processing 10,000 requests/sec (QPS). Each request contains a 50 KB JSON payload with nested structures. We validate each request using a JSON Schema validator. Additionally, we check a username field against a regular expression containing a nested quantifier: ^([a-zA-Z0-9]+)*$.
Governing Formulas
The total CPU cores $N_{\text{cores}}$ required solely to handle payload schema validation is:
The execution steps $S$ (and therefore CPU time) for an exponential backtracking regex (ReDoS) on a mismatched input string of length $L$ is:
For a safe linear-time regex engine (like RE2), the steps are linear:
Step-by-Step Scenario Analysis
Let's compare the processing time and CPU core requirements for different validation techniques and input shapes at 10,000 QPS:
| Validation Strategy / Input | Validation Time (per req) | Total CPU Cores Required | System Capacity / Safety |
|---|---|---|---|
| No Validation (Blind Bind) | 0.05 ms | $10,000 \times 0.00005\text{ s} = \mathbf{0.5\text{ cores}}$ | Fast but highly vulnerable to injection/corruption |
| Interpretive Schema Validator | 1.20 ms | $10,000 \times 0.00120\text{ s} = \mathbf{12.0\text{ cores}}$ | Secure but slow; requires substantial compute overhead |
| Compiled Schema Validator (Ajv) | 0.15 ms | $10,000 \times 0.00015\text{ s} = \mathbf{1.5\text{ cores}}$ | Secure and optimized (saves 10.5 cores over interpretive) |
ReDoS Input (Length 30: aaaa...a!) |
$2^{30}$ steps $\approx 10.7$ seconds | $1 \times 1.0\text{ core} = \mathbf{1.0\text{ core locked}}$ | DENIAL OF SERVICE (1 thread permanently locked at 100% CPU) |
| Safe Regex Engine (RE2) | 0.01 ms | $10,000 \times 0.00001\text{ s} = \mathbf{0.1\text{ cores}}$ | Protected against ReDoS attacks |
Decision Math: Preventing ReDoS Outages
If an attacker sends a malicious 30-character string to our vulnerable regex engine, a single request locks 1 CPU thread for 10.7 seconds. If we have a pool of 8 worker threads, the attacker only needs to send 8 requests in a single second to exhaust our thread pool, causing a total API gateway outage (100% CPU lock, all other users receive 504 timeouts).
To mitigate this:
- Enforce length limits: Limit the
usernamefield to a maximum of 20 characters in the JSON schema. At $L=20$, the maximum backtracking steps drop to $2^{20} = 1,048,576$ (taking $\approx 10$ ms of CPU), which is survivable. - Switch engines: Use linear-time regular expression libraries (such as RE2 or Rust's regex crate) which structurally forbid backtracking lookarounds and nested quantifiers, guaranteeing $O(L)$ execution time regardless of input pattern.
When not to apply this control naively
- Do not treat security checklists as complete without a threat model for your specific assets and attackers.
- Do not put secrets in JWTs you cannot revoke without short TTL + denylist or opaque tokens.
- Skip inventing auth protocols — prefer OAuth2/OIDC with a maintained library.
- CORS is not authorization; never use origin checks as the only access control.
Why defense-in-depth has order
Authenticate identity, authorize action, validate input, limit rate, encrypt in transit and at rest, log for forensics. Reordering (e.g. validate after expensive work) creates DoS and bypass paths. Standout engineers map each control to a STRIDE threat it kills.
Failure under attack / misconfig
| Signal | Likely issue | Response |
|---|---|---|
| 401/403 spike same key fingerprint | Stolen or stuffed credentials | Revoke, step-up, rate limit by IP+key |
| Sequential ID 404/200 pattern | IDOR / enumeration | Authz on object, opaque ids, rate limit |
| TLS handshake failures post-deploy | Chain/SNI/cipher mismatch | openssl s_client, rollback cert |
🧠 Quick check
1. Why is an allow-list stronger than a deny-list for validating API input?
A deny-list can only block known bad input — attackers find encoding tricks or novel patterns to slip through. An allow-list rejects everything outside the defined valid set, so unknown-bad inputs fail closed.
2. What makes parameterized queries effective against SQL injection?
With a parameterized query, the database receives the query structure first and compiles it. The user-supplied values are bound afterward as data — the parser has already finished, so injection is structurally impossible, not just unlikely.
3. A PATCH /users/42 endpoint automatically applies all fields from the request body to the user record. Why is this a security problem?
Mass-assignment lets a caller set fields they should not control — roles, billing status, internal flags. The fix is an explicit allow-list of writable fields, so unknown or protected fields are ignored regardless of what the client sends.
4. Which of these is the most dangerous input-handling approach?
Concatenating user input into a shell command enables command injection — the attacker uses ;, |, or $() to execute arbitrary OS commands. Options A and C are both valid allow-list approaches.
✍️ Exercise: spot and fix the injection (try before opening)
The following Python snippet is the handler for GET /v1/products?category=electronics. Identify the vulnerability, explain how it could be exploited, and rewrite it to be safe.
category = request.args.get('category')
result = db.execute(f"SELECT * FROM products WHERE category='{category}'")
Model answer:
Vulnerability: The category query parameter is interpolated directly into the SQL string. An attacker sends ?category=' OR '1'='1 to dump every product, or ?category='; DROP TABLE products; -- to attempt destructive commands.
# Safe version — parameterized query
category = request.args.get('category')
# Allow-list: only accept known category slugs
valid_categories = {'electronics', 'clothing', 'books'}
if category not in valid_categories:
return 400, {"error": "invalid category"}
result = db.execute(
"SELECT * FROM products WHERE category=%s",
(category,) # ← tuple of values; db driver handles escaping
)
Rubric: ✓ named the attack (SQL injection) ✓ showed a concrete exploit payload ✓ fixed with parameterized query ✓ added allow-list as a second layer ✓ did not rely solely on escaping.
Key takeaways
- Never trust the client — all validation that matters runs server-side; client-side checks are UX, not security.
- Allow-list (define what is valid and reject the rest) is stronger than deny-list (define what is forbidden).
- Parameterized queries eliminate SQL injection by separating SQL structure from user data — the database never parses user input as code.
- Validate type, length, and range for every input field; set
additionalProperties: falseto prevent mass-assignment. - Output encoding is the complement to input validation — it prevents stored data from executing when rendered.