Input Validation and Sanitization
Treat every byte from the client as hostile until proven safe — validate at the boundary, sanitize before output.
Here's the mindset that prevents half the attacks in the last lesson: the client is not your friend. Not because your users are villains, but because anyone can bypass your pretty React form and hit your API directly with curl, sending whatever they like — a 10 MB username, an age of -1, a name that's secretly a snippet of code.
So you build a wall at the edge of your system and let nothing through until it's been checked. Two jobs live at that wall, and people mix them up constantly, so let's name them clearly.
from pydantic import BaseModel, EmailStr, Field, field_validator
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_]+$')
email: EmailStr
age: int = Field(ge=13, le=120) # ge = 'greater than or equal'
bio: str = Field(max_length=500, default="")
@field_validator('username')
@classmethod
def normalize(cls, v: str) -> str:
return v.lower() # store usernames consistentlyIf the incoming JSON breaks any rule, FastAPI rejects it with a 422 Unprocessable Entity before your route function ever runs. Your business logic only ever sees data that already passed the wall.
Why restrict a username with pattern=r'^[a-zA-Z0-9_]+$' (letters, digits, underscore only)?
- AIt's an allowlist — permitting only safe characters blocks names like `<script>` or `'; DROP TABLE--` from ever entering the system
- BIt makes usernames case-insensitive
- CFastAPI requires every string field to have a regex
- DIt enforces a minimum password strength
The second job, sanitization, happens on the way out — specifically when you render user text back into a page. If your API ever produces HTML (or a frontend drops your data straight into the DOM), unescaped input becomes XSS (Cross-Site Scripting): the attacker's text is treated as live code in someone else's browser.
import bleach # pip install bleach
def safe_html(raw: str) -> str:
return bleach.clean(
raw,
tags=['p', 'br', 'strong', 'em', 'ul', 'ol', 'li'], # allowlist again!
attributes={}, # no attributes at all
)Notice the same instinct as validation: an allowlist of harmless tags, everything else stripped. Modern template engines (Jinja2, React's JSX) also auto-escape by default — {{ user_input }} becomes inert text, not markup. The danger is only when you deliberately turn escaping off (| safe, dangerouslySetInnerHTML).
What is an XSS attack?
- AAttacker-supplied text is rendered as live HTML/JavaScript, so their script runs in other users' browsers
- BThe attacker overwhelms the server with requests
- CThe attacker reads files off the server's disk
- DThe attacker intercepts traffic on the network
Validation happens at the API boundary, so bad data is rejected ______ your business logic ever runs.
An endpoint takes a redirect_url and sends the user there after login. What must you validate about it?
- AThat it points to a domain on your allowlist — not an attacker-controlled site
- BThat it's under 2000 characters
- CThat it uses HTTPS
- DNothing — redirect URLs are inherently safe
- The client is hostile by default — anyone can skip your UI and post raw JSON to your API. Build a wall at the boundary.
- Validation (on the way in) checks shape: use Pydantic
Fieldconstraints so bad data is rejected with 422 before your logic runs. - Sanitization / escaping (on the way out) stops XSS — attacker text executing as code in another user's browser. Auto-escaping templates handle most of it; only explicit 'raw HTML' opt-outs are risky.
- Prefer allowlists (permit only known-safe) over blocklists everywhere — usernames, HTML tags, redirect domains. You can't enumerate all the bad; you can enumerate the good.
Next: the security controls the browser itself gives you — headers — and one gotcha called CORS.