Our authentication and authorization are already solid (Part 3, Part 4). Now let's lock down the next layer: data coming INTO the system can't be used to attack the system itself. This is about Injection, a threat we covered conceptually in Part 2.
Proving SQL Injection Doesn't Work Against Our Code
Since the start of the FastAPI series, we've always used SQLModel to talk to the database — never writing raw SQL via string concatenation. That's not a coincidence; it's exactly what makes us automatically immune to SQL injection, WITHOUT writing a single line of extra defensive code.
Let's prove it directly — try "attacking" our own API with a classic SQL injection payload through the title field:
curl -X POST localhost:8000/api/tasks -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"title\": \"'; DROP TABLE task; --\"}"
{"id":2,"title":"'; DROP TABLE task; --","done":false,"priority":"medium","created_at":"..."}
Check the database directly afterward:
SELECT id, title FROM task;
id | title
----+------------------------
1 | Alice's secret task
2 | '; DROP TABLE task; --
The task table is still intact, all its data is still there. A payload designed to wipe out the entire table just got stored as an ORDINARY STRING — exactly like any other task title. This proves the concept from Part 2: SQLModel/SQLAlchemy automatically uses a parameterized query under the hood.
Why This Happens (and Why We're Already Safe)
Behind the scenes, when we write:
task = Task(title=payload.title, priority=payload.priority, owner_id=current_user.id)
session.add(task)
session.commit()
SQLModel/SQLAlchemy NEVER inserts payload.title directly into the SQL command text. Instead, the actual SQL command sent to the database looks roughly like this:
INSERT INTO task (title, priority, owner_id) VALUES ($1, $2, $3)
With $1, $2, $3 as placeholders — the actual values are sent SEPARATELY from the command text, via the database protocol. The database itself guarantees $1 is ALWAYS treated as a single data value, no matter what it contains — including if its content happens to look like an SQL command. This is fundamentally different from manually building an SQL string (f"... WHERE title = '{title}'"), which lets a variable's content get "read" as part of the command.
Rule of thumb: as long as you use an ORM/query builder (SQLModel, SQLAlchemy, Django ORM, and similar) and NEVER build SQL manually via string formatting/concatenation from user input, you're automatically protected against SQL injection. The danger only appears when a special requirement forces you to write raw SQL — if that happens, you MUST use the parameter binding provided by the library, never Python string formatting.
Pydantic Validation as an Active Defense Layer, Not a Formality
Recall the Field constraints from the FastAPI series — in a security context, these aren't just about "tidy data," they're the first line of defense before data ever gets processed:
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
priority: Priority = Priority.medium
max_length=200 isn't just cosmetic. Without this limit, someone could send a title millions of characters long, repeatedly — burdening the database, memory, and bandwidth, one form of the Unrestricted Resource Consumption touched on in Part 2. This validation happens BEFORE our path operation function ever runs at all — a request that violates the rule gets rejected with 422 at the outermost layer, never getting anywhere near business logic or the database.
curl -X POST localhost:8000/api/tasks -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"title": ""}'
# 422 — rejected before it's ever processed
About XSS — Why It's Not (and Doesn't Need to Be) Handled Here
Our task-tracker-api is a pure JSON API — it never renders HTML. Cross-Site Scripting (XSS) happens when user-controllable data gets rendered as HTML WITHOUT being escaped, letting an attacker inject a <script> that executes in the victim's browser. Since we never return HTML, XSS isn't a direct threat at this API layer.
But that doesn't mean "permanently safe." If, someday, data from this API gets displayed in a frontend (React, Vue, or anything else) WITHOUT proper output encoding, a title like <script>alert('hacked')</script> could execute in the browser of any other user who views it. The good news: almost every modern frontend framework (React, Vue) automatically escapes data by default when rendering it as text — XSS gaps usually show up precisely when a developer explicitly BYPASSES that protection (like dangerouslySetInnerHTML in React) without additional sanitization.
The key principle: the defense against XSS belongs at the RENDER point (output encoding in the frontend), not the STORAGE point (API/database). Filtering out <script> on the way in is fragile and easy to bypass (there are dozens of ways to rewrite an XSS payload); correct output encoding at the render point is far more reliable, since it doesn't depend on guessing every possible payload.
Summary
Field constraints) is an active defense layer, not a formality — it keeps excessive data from ever reaching business logic.