How to Fuzz-Test a FastAPI API With Schemathesis and Find Server Errors, Missing Auth, and IDOR From Its OpenAPI Schema
A hands-on Schemathesis 4.32.1 tutorial that fuzzes a deliberately flawed FastAPI API from its OpenAPI schema, fixes 19 failures step by step, adds a two-user check that catches an IDOR bug, and...
What you will build, and why fuzzing from the schema matters
By the end of this tutorial you will have pointed Schemathesis, a tool that generates API tests from an OpenAPI schema, at a small FastAPI API called Parcel Desk, read the failures from its first run, and fixed them one at a time until a single finding is left, which you will decide to accept. Along the way you will add a two-user check that catches a broken object level authorization bug (often called IDOR), run the same checks inside pytest without starting a server, and wire the whole thing into CI with an exit code and a baseline file for failures you decide to accept.
Table Of Content
- What you will build, and why fuzzing from the schema matters
- Prerequisites
- Step 1: Create the project and install the tools
- Step 2: Create the Parcel Desk API
- Step 3: Run Schemathesis for the first time
- Step 4: Fix the documentation gaps first (ST103)
- Step 5: Reproduce and fix the server errors (ST101)
- How many examples are enough?
- Step 6: Fix the authentication gap (ST401, ST203, ST201)
- Step 7: Catch the delete bug with stateful testing (ST301)
- Step 8: Add a second user and catch the IDOR (ST402)
- Step 9: Decide what to do about ST205 (Allow header mismatch)
- Step 10: Run the same checks inside pytest
- Step 11: Put it in CI: exit codes, a baseline, and reports
- Step 12: See what a clean run still cannot tell you
- The scorecard
- Check that everything works end to end
- Common mistakes and gotchas
- Aiming at a real environment
- Trusting a green run too much
- Forgetting the example database
- Comparing failure counts between runs
- Running from a subfolder
- Putting a baseline key under a TOML table
- Where to go next
- Sources and method
Hand-written API tests check the cases you thought of. Bugs live in the cases you did not think of: a name with an accented letter, a zone number the price table forgot, a 23-digit id, a DELETE followed by a GET. FastAPI already publishes a machine-readable description of your API at /openapi.json. That description is the OpenAPI schema. It says which paths exist, which parameters they take, which types and limits those parameters have, and which status codes and response bodies each operation promises. Schemathesis reads that contract, generates thousands of requests (valid ones and deliberately invalid ones), sends them, and checks every response against what the contract promised. Its documentation sums up the idea like this: “Schemathesis automatically generates property-based tests from your OpenAPI or GraphQL schema, chains operations into realistic workflows, and exercises the edge cases that break your API.”
Five terms come up all the time, so here they are before we start.
| Term | What it means in this tutorial |
|---|---|
| OpenAPI schema | The JSON document your API serves at /openapi.json. It is the contract Schemathesis tests against. |
| Property-based testing | Generating many inputs and checking rules that must hold for all of them, instead of checking a few fixed examples. Schemathesis is built on the Hypothesis library; the Hypothesis tutorial on this site introduces the idea in plain Python. |
| Check | A rule applied to every response, for example that the status is not a 5xx or that the status code appears in the schema. Each failing check has a code like ST101. |
| Phase | One stage of a run. Schemathesis works in four: examples, coverage, fuzzing and stateful. |
| Seed | A number that makes the random generation repeatable, so a failing run can be run again. |
Safety first. Schemathesis sends thousands of requests, including DELETE and PATCH with junk data. Run it only against an app you own and a database you can throw away, never against production or against someone else’s service.
Along the way you will reproduce these results:
- The first run reports 19 failures in about 2.4 seconds. Twelve of them are the same documentation gap, and fixing that one gap is the quickest win (Step 4).
- Four different inputs make the app answer 500. The first run found three of them; a 23-digit parcel id was found by only 6 of 10 seeds at the default number of examples (Step 5).
- One bug is reported three times under three different codes (Step 6), and a DELETE bug is only visible when requests are chained (Step 7).
- An IDOR bug stays invisible until you give Schemathesis two users (Step 8).
- The same checks run inside pytest in about 1.5 seconds on the broken app and pass on the fixed one (Step 10).
- After a clean run, two bugs are still there. Schemathesis cannot see them, and two small example tests can (Step 12).
Prerequisites
- Python 3.11 or newer. Schemathesis 4.32.1 declares
Requires-Python >=3.11. Everything here was run on Python 3.13.14 on Windows 11. - pip and venv, which ship with Python, and two terminal windows (one for the server, one for the tests).
- Basic FastAPI and HTTP knowledge. You should know what a route, a status code and a bearer token are. You do not need to know Schemathesis.
- curl (optional) for reproducing failures by hand. The commands use single quotes the way Git Bash, macOS and Linux shells do. In Windows PowerShell or cmd.exe, run them from Git Bash instead, because those shells treat the quotes inside JSON bodies differently.
- Pinned versions: Schemathesis 4.32.1, FastAPI 0.149.0, Uvicorn 0.54.0, pytest 9.1.1 and httpx2 2.14.0. Newer versions may print slightly different counts.
Step 1: Create the project and install the tools
Make a folder for the project, create a virtual environment so the install does not touch your system Python, and install the five packages.
mkdir parcel-desk
cd parcel-desk
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "schemathesis==4.32.1" "fastapi==0.149.0" "uvicorn==0.54.0" "pytest==9.1.1" "httpx2==2.14.0"
schemathesis --version
schemathesis, version 4.32.1
Schemathesis installs two command names, schemathesis and st. They are the same program, and its output uses the short one in hints such as st replay. The httpx2 package is the HTTP client FastAPI’s TestClient needs; its import error message tells you to pip install httpx2. We use that client in Step 12.
Check: the version command prints 4.32.1. If it prints an older number, your shell is using another Python; re-activate the virtual environment.
Step 2: Create the Parcel Desk API
Parcel Desk lets a signed-in user create parcels, list their own parcels, read, change and delete a parcel, and ask for a price quote. Two users exist, alice and bob, each with a fixed token. Data lives in an in-memory SQLite database, so every restart starts clean.
| Operation | What it does | Needs a token |
|---|---|---|
GET /health |
Returns {"status": "ok"} |
No |
GET /quote |
Price for a weight, a zone (1 to 9) and a service | No |
POST /parcels |
Create a parcel (express parcels also print a label) | Yes |
GET /parcels |
List your parcels, optionally filtered by status | Yes |
GET, PATCH, DELETE /parcels/{parcel_id} |
Read, change or delete one parcel | Yes |
GET /reports/daily |
Count of parcels | Yes |
Save this as app.py. Read it the way you would read a colleague’s pull request, because the file is deliberately flawed. Some of the flaws are ordinary bugs, some are things FastAPI does by default, and one is easy to miss even when you read the code. Do not hunt for them yet. A scorecard near the end lists every one and shows which step found it. Keep a copy of this file as app_original.py, because Step 10 needs it.
# app.py
"""Parcel Desk API, first draft. Deliberately flawed: run it on localhost only."""
import sqlite3
import threading
from typing import Annotated, Literal
from fastapi import Depends, FastAPI, HTTPException, Path, Query
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
TOKENS = {"alice-token": "alice", "bob-token": "bob"}
ZONE_RATES = {1: 500, 2: 650, 3: 800, 4: 950, 5: 1100, 6: 1300}
Service = Literal["standard", "express"]
Status = Literal["created", "shipped", "delivered"]
ParcelId = Annotated[int, Path()]
app = FastAPI(title="Parcel Desk", version="1.0.0")
bearer = HTTPBearer()
bearer_optional = HTTPBearer(auto_error=False)
db = sqlite3.connect(":memory:", check_same_thread=False)
db.row_factory = sqlite3.Row
lock = threading.Lock()
db.execute(
"CREATE TABLE parcels (id INTEGER PRIMARY KEY, owner TEXT, recipient TEXT, "
"weight_grams INTEGER, service TEXT, status TEXT, price_cents INTEGER, "
"deleted INTEGER DEFAULT 0)"
)
class ParcelIn(BaseModel):
recipient: str = Field(min_length=1, max_length=80)
weight_grams: int = Field(ge=1, le=30000)
service: Service = "standard"
class Parcel(ParcelIn):
id: int
owner: str
status: Status
price_cents: int
class ParcelPatch(BaseModel):
recipient: str | None = Field(default=None, min_length=1, max_length=80)
status: Status | None = None
def current_user(creds: Annotated[HTTPAuthorizationCredentials, Depends(bearer)]) -> str:
user = TOKENS.get(creds.credentials)
if user is None:
raise HTTPException(status_code=401, detail="Invalid token")
return user
def user_or_guest(
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_optional)],
) -> str:
return TOKENS.get(creds.credentials, "guest") if creds else "guest"
def price_cents(weight_grams: int, service: str, zone: int = 1) -> int:
base = ZONE_RATES[zone] + (weight_grams // 500) * 40
return base * 2 if service == "express" else base
def print_label(recipient: str) -> bytes:
return recipient.upper().encode("ascii")
def fetch(parcel_id: int):
with lock:
return db.execute("SELECT * FROM parcels WHERE id = ?", (parcel_id,)).fetchone()
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/quote")
def quote(
weight_grams: Annotated[int, Query(ge=1, le=30000)],
zone: Annotated[int, Query(ge=1, le=9)] = 1,
service: Service = "standard",
) -> dict[str, int]:
return {"price_cents": price_cents(weight_grams, service, zone)}
@app.post("/parcels", status_code=201, response_model=Parcel)
def create_parcel(body: ParcelIn, user: Annotated[str, Depends(current_user)]):
if body.service == "express":
print_label(body.recipient)
price = price_cents(body.weight_grams, body.service)
with lock:
cur = db.execute(
"INSERT INTO parcels (owner, recipient, weight_grams, service, status, price_cents) "
"VALUES (?, ?, ?, ?, 'created', ?)",
(user, body.recipient, body.weight_grams, body.service, price),
)
db.commit()
row = db.execute("SELECT * FROM parcels WHERE id = ?", (cur.lastrowid,)).fetchone()
return dict(row)
@app.get("/parcels", response_model=list[Parcel])
def list_parcels(
user: Annotated[str, Depends(current_user)],
status: Annotated[str | None, Query(max_length=20)] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
sql = "SELECT * FROM parcels WHERE owner = ? AND deleted = 0"
if status:
sql += f" AND status = '{status}'"
with lock:
rows = db.execute(sql + " LIMIT ?", (user, limit)).fetchall()
return [dict(r) for r in rows]
@app.get("/parcels/{parcel_id}", response_model=Parcel)
def get_parcel(parcel_id: ParcelId, user: Annotated[str, Depends(current_user)]):
row = fetch(parcel_id)
if row is None:
raise HTTPException(status_code=404, detail="Parcel not found")
return dict(row)
@app.patch("/parcels/{parcel_id}", response_model=Parcel)
def patch_parcel(
parcel_id: ParcelId, body: ParcelPatch, user: Annotated[str, Depends(current_user)]
):
row = fetch(parcel_id)
if row is None or row["owner"] != user:
raise HTTPException(status_code=404, detail="Parcel not found")
for column, value in body.model_dump(exclude_none=True).items():
with lock:
db.execute(f"UPDATE parcels SET {column} = ? WHERE id = ?", (value, parcel_id))
db.commit()
return dict(fetch(parcel_id))
@app.delete("/parcels/{parcel_id}", status_code=204)
def delete_parcel(parcel_id: ParcelId, user: Annotated[str, Depends(current_user)]):
row = fetch(parcel_id)
if row is None or row["owner"] != user:
raise HTTPException(status_code=404, detail="Parcel not found")
with lock:
db.execute("UPDATE parcels SET deleted = 1 WHERE id = ?", (parcel_id,))
db.commit()
@app.get("/reports/daily")
def daily_report(user: Annotated[str, Depends(user_or_guest)]) -> dict[str, int]:
with lock:
total = db.execute("SELECT COUNT(*) FROM parcels WHERE deleted = 0").fetchone()[0]
return {"parcels": total}
Start the server in the first terminal and leave it running. Do not run this app on a machine other people can reach.
uvicorn app:app --port 8000
INFO: Started server process [18856]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
Before testing, look at what the API promises. FastAPI builds the schema from your code, so this tiny script reads it and prints each operation, the status codes the schema documents for it, and whether it declares that a token is required. Save it as show_schema.py and run it from the same folder.
# show_schema.py: print every operation, the status codes the schema documents, and whether it needs a token
import sys
from app import app
for path, operations in app.openapi()["paths"].items():
for method, op in operations.items():
codes = ", ".join(sorted(op["responses"]))
needs_token = "yes" if op.get("security") else "no"
print(f"{method.upper():7} {path:22} documented: {codes:19} token: {needs_token}")
python show_schema.py
GET /health documented: 200 token: no
GET /quote documented: 200, 422 token: no
POST /parcels documented: 201, 422 token: yes
GET /parcels documented: 200, 422 token: yes
GET /parcels/{parcel_id} documented: 200, 422 token: yes
PATCH /parcels/{parcel_id} documented: 200, 422 token: yes
DELETE /parcels/{parcel_id} documented: 204, 422 token: yes
GET /reports/daily documented: 200 token: yes
This table is the contract. Every operation promises a success code and, where it takes input, a 422 (FastAPI adds that one itself for validation errors). Nothing promises a 401, a 404 or a 500. Keep that in mind, because Schemathesis will compare what the server really does against exactly this list.
Check: opening http://127.0.0.1:8000/openapi.json in a browser shows JSON, and show_schema.py prints eight lines.
Step 3: Run Schemathesis for the first time
Schemathesis reads settings from a file called schemathesis.toml in the folder you run it from, or in a parent folder. Create one with three settings.
seed = 1
[generation]
database = "none"
[auth.openapi.HTTPBearer]
bearer = "alice-token"
Here is what each part does.
seed = 1fixes the random seed. Without it Schemathesis picks a random seed and prints it at the end of the run, and you can pass it back later to repeat the run. The configuration reference says that “Setting the same seed value will result in the same sequence of generated test cases.” We pin it so that your output matches this article.database = "none"turns off the example database. By default, in the words of the configuration reference, “Schemathesis creates a directory-based example database in your current working directory under .hypothesis/examples.” Failing inputs are stored there and replayed on the next run, which is handy in daily use and confusing while you compare runs.[auth.openapi.HTTPBearer]sends the tokenalice-tokenwherever the schema declares a security scheme with that name.HTTPBeareris the name FastAPI gives to the scheme of itsHTTPBearerclass, (you can check it withapp.openapi()["components"]["securitySchemes"], which listsHTTPBearer). The authentication guide describes this form as authentication that follows “your OpenAPI schema’s security definitions.”
Now run it from the second terminal, in the project folder.
schemathesis run http://127.0.0.1:8000/openapi.json
The first part of the output tells you what was loaded and how each phase went.
Schemathesis v4.32.1
━━━━━━━━━━━━━━━━━━━━
✅ Loaded specification from http://127.0.0.1:8000/openapi.json (in 0.24s)
Base URL: http://127.0.0.1:8000/
Specification: Open API 3.1.0
Operations: 8 selected / 8 total
✅ API capabilities:
Supports NULL byte in headers: ✘
Accepts backslash and control characters in URL paths: ✓
⏭ Examples (in 0.00s)
⏭ 8 skipped
❌ Coverage (in 1.17s)
✅ 1 passed ❌ 7 failed
❌ Fuzzing (in 0.17s)
✅ 2 passed ❌ 6 failed
❌ Stateful (in 0.99s)
Scenarios: 54
API Links: 10 covered / 10 selected / 10 total (10 inferred)
✅ 43 passed ❌ 11 failed
============================================= FAILURES =============================================
Read it top to bottom. Schemathesis loaded the schema (an OpenAPI 3.1 document with eight operations), then probed two capabilities of the server before testing. After that come the four phases. The documentation says that “Schemathesis is structured as four phases (Examples, Coverage, Fuzzing, Stateful)”, and in this run they behaved like this:
- Examples sends the
exampleandexamplesvalues declared in your schema. Ours declares none, so all eight operations are skipped. - Coverage “aims to exhaustively cover boundary values for every constraint defined in the schema”, for example values at each limit and just outside it.
- Fuzzing sends random data within the schema’s constraints, plus unusual values.
- Stateful chains operations so that, for example, an id returned by
POST /parcelsfeeds a laterGET /parcels/{parcel_id}. The header reports 54 scenarios and 10 links, all inferred from the schema without any help from us.
The last three phases “generate both valid and invalid data by default”, which is why some of the requests you will see are deliberately wrong. Below the header come the failures, one block per operation. Here are two of them.
____________________________________________ GET /quote ____________________________________________
1. Test Case ID: 7MWXEG
- ST101 Server error
- ST103 Undocumented status code
Received: 500
Documented: 200, 422
[500] Internal Server Error:
`Internal Server Error`
Reproduce with:
curl -X GET 'http://127.0.0.1:8000/quote?weight_grams=1&zone=8&service=standard'
st replay 7MWXEG
This block is a good template for reading all the others. The heading names the operation. Each line that starts with ST is a failed check, here two at once: ST101 Server error (the server answered 500) and ST103 Undocumented status code (500 is not in the documented list 200, 422). The curl line reproduces the failing request exactly, with the token hidden as [Filtered] because Schemathesis obscures sensitive values in its output. The st replay line re-sends the stored request by its Test Case ID, and you can add --url if your server is somewhere else. The input was found by shrinking: Schemathesis tries simpler and simpler inputs that still fail, which is why the request is a boring weight_grams=1 and not a random number.
________________________________________ GET /reports/daily ________________________________________
1. Test Case ID: bQStL3
- ST401 API accepts requests without authentication
Expected 401 or 403, got `200 OK` for `GET /reports/daily`
- ST201 Schema-violating request accepted
Invalid data should have been rejected
Expected: 400, 401, 403, 404, 405, 406, 409, 415, 422, 428, 429, 5xx
Invalid component: Missing `Authorization` at header
- ST203 Missing required header accepted
Got 200 when missing required 'Authorization' header, expected 401
[200] OK:
`{"parcels":0}`
Reproduce with:
curl -X GET http://127.0.0.1:8000/reports/daily
st replay bQStL3
One operation, three codes. You will see why in Step 6. For now, note that the number of failures in a summary is not the number of bugs.
The summary at the end counts everything.
Failures:
❌ ST401 API accepts requests without authentication: 1
❌ ST301 Deleted resource still available: 1
❌ ST101 Server error: 3
❌ ST203 Missing required header accepted: 1
❌ ST201 Schema-violating request accepted: 1
❌ ST103 Undocumented status code: 12
Test cases:
415 generated, 14 found 19 unique failures, 10 skipped
Seed: 1
======================================= 19 failures in 2.38s =======================================
The command exits with code 1 whenever any check failed, which is what lets CI stop a build (Step 11). Here is what the 19 failures are.
| Code | Count | What it means |
|---|---|---|
ST103 |
12 | The server answered with a status code the schema does not document: 401, 404 or 400 on several operations. |
ST101 |
3 | The server answered with a 5xx status. Its checks reference says this check “catches server-side errors: 5xx status codes”. |
ST401, ST203, ST201 |
1 each | All three are about GET /reports/daily accepting a request without credentials. |
ST301 |
1 | A deleted parcel can still be read (found by the stateful phase). |
Check: with seed 1 and the same versions you should see 19 failures and the same six codes. Small differences in count are normal across versions. If you see a connection error, the server from Step 2 is not running.
Step 4: Fix the documentation gaps first (ST103)
Twelve of 19 failures are one kind, and that kind is cheap to fix, so start there. The checks reference defines ST103 as a check that “verifies the response status code is documented in the schema for the operation (or a default response is defined)”. In plain words: the server behaves one way and the contract says another. When a check fails you have two choices, change the code or change the contract. Here the server is right. A missing token really should give 401, a missing parcel really should give 404, and a body that is not JSON really does give 400. The contract is incomplete, because FastAPI documents 422 on its own but cannot guess which other statuses your code raises. Its documentation notes that “You can declare additional responses, with additional status codes, media types, descriptions, etc.”
From here on, each fix is shown as a block of code to put into app.py. The comment on the first line says what to do: replace the named top-level definitions with the versions below, or add new ones next to them. Everything else in the file stays as it is. This first fix adds a small ErrorBody model above the app and replaces the app = FastAPI(...) line so that three error responses are documented for every operation.
# app.py (add ErrorBody; replace app)
class ErrorBody(BaseModel):
detail: str
app = FastAPI(
title="Parcel Desk",
version="1.0.0",
responses={
400: {"model": ErrorBody, "description": "The request body is not valid JSON"},
401: {"model": ErrorBody, "description": "Missing or invalid token"},
404: {"model": ErrorBody, "description": "Parcel not found"},
},
)
Restart uvicorn so it loads the change (stop it with Ctrl+C and start it again), then run the schema printer and Schemathesis again.
python show_schema.py
schemathesis run http://127.0.0.1:8000/openapi.json
GET /health documented: 200, 400, 401, 404 token: no
GET /quote documented: 200, 400, 401, 404, 422 token: no
POST /parcels documented: 201, 400, 401, 404, 422 token: yes
GET /parcels documented: 200, 400, 401, 404, 422 token: yes
GET /parcels/{parcel_id} documented: 200, 400, 401, 404, 422 token: yes
PATCH /parcels/{parcel_id} documented: 200, 400, 401, 404, 422 token: yes
DELETE /parcels/{parcel_id} documented: 204, 400, 401, 404, 422 token: yes
GET /reports/daily documented: 200, 400, 401, 404 token: yes
Every operation now documents 400, 401 and 404. This is a shortcut, and the table shows its price: it also lists 401 on GET /health, which never asks for a token. In a real API, declare the codes per route with the responses= argument of each decorator so the contract stays precise. For a first pass the shortcut removes the noise.
Failures:
❌ ST401 API accepts requests without authentication: 1
❌ ST301 Deleted resource still available: 1
❌ ST101 Server error: 3
❌ ST205 Allow header mismatch: 2
❌ ST203 Missing required header accepted: 1
❌ ST201 Schema-violating request accepted: 1
❌ ST103 Undocumented status code: 3
Test cases:
1698 generated, 7 found 12 unique failures, 10 skipped
Seed: 1
======================================= 12 failures in 9.67s =======================================
The total dropped from 19 to 12. ST103 went from 12 to 3, and the three left are the 500 responses, which are not a documentation problem. But two new lines appeared, ST205 twice, even though we only added documentation. They were always there. The first run simply never reached them. The baseline guide explains that “a scenario otherwise stops at the first failure on each operation”, and the same seems to hold for the other phases, because an operation that fails early does not get every later check. You can confirm it on the original app (copy app_original.py into place for a moment): add --continue-on-failure, which the command’s help describes as “Continue executing all test cases within a scenario, even after encountering failures”.
Failures:
❌ ST401 API accepts requests without authentication: 1
❌ ST301 Deleted resource still available: 1
❌ ST101 Server error: 3
❌ ST205 Allow header mismatch: 2
❌ ST203 Missing required header accepted: 1
❌ ST201 Schema-violating request accepted: 1
❌ ST103 Undocumented status code: 13
Test cases:
2188 generated, 17 found 22 unique failures, 10 skipped
Seed: 1
====================================== 22 failures in 10.77s =======================================
On the original app, the first run with that flag reports 22 unique failures instead of 19, and ST205 is among them. The lesson is that a failure count is not a progress bar. Fix a batch, run again, and expect the picture to change. We will deal with ST205 in Step 9.
Check: after the Step 4 change you should see 12 failures, and the only ST103 lines left are the three 500 responses.
Step 5: Reproduce and fix the server errors (ST101)
A 500 means your code crashed instead of answering. Schemathesis can tell you which request crashed the server but not why, because the response body of a 500 is just Internal Server Error. The reason is in the log of the terminal where uvicorn runs. The first run found three of these four problems (for the SQL one it hit a NUL character in the filter rather than a quote), and the 23-digit id is the one seed 1 did not hit. Replay all four against the Step 4 app and watch the status line and the last line of the traceback in the uvicorn terminal.
curl -i "http://127.0.0.1:8000/quote?weight_grams=1&zone=8&service=standard"
curl -i -X POST http://127.0.0.1:8000/parcels -H "Authorization: Bearer alice-token" -H "Content-Type: application/json" -d '{"recipient": "Zo\u00eb", "weight_grams": 1, "service": "express"}'
curl -i -H "Authorization: Bearer alice-token" "http://127.0.0.1:8000/parcels?status=%27"
curl -i -H "Authorization: Bearer alice-token" http://127.0.0.1:8000/parcels/99999999999999999999999
Here are the results of those four commands, condensed by a small script of mine to the status line, the start of the response body and the last line of the server log. Your terminal will show the complete curl output and the full traceback.
--- quote with zone 8
HTTP/1.1 500 Internal Server Error | Internal Server Error
server log: KeyError: 8
--- express parcel for Zo\u00eb
HTTP/1.1 500 Internal Server Error | Internal Server Error
server log: UnicodeEncodeError: 'ascii' codec can't encode character '\xcb' in position 2: ordinal not in range(128)
--- status filter with one quote
HTTP/1.1 500 Internal Server Error | Internal Server Error
server log: sqlite3.OperationalError: unrecognized token: "''' LIMIT ?"
--- a 23-digit parcel id
HTTP/1.1 500 Internal Server Error | Internal Server Error
server log: OverflowError: Python int too large to convert to SQLite INTEGER
Each crash has a different cause and a different right answer.
| Request | What went wrong | Fix |
|---|---|---|
zone=8 |
The schema allows zones 1 to 9, but the price table has rates for 1 to 6, so ZONE_RATES[8] raises KeyError. |
Make the contract match the code: allow zones 1 to 6. (Adding three rates would be the other valid choice.) |
| Express parcel for “Zoë” | The label printer only takes ASCII, and encode("ascii") raises UnicodeEncodeError for any other letter. |
Degrade instead of crashing: errors="replace" prints a question mark. Rejecting such names with a 422 would also be fine, but it is a product decision, not a coding one. |
| A status filter containing a quote | The status is pasted into the SQL text, so one quote breaks the statement (sqlite3.OperationalError). |
Use bound parameters, and make the schema say what is allowed: status becomes one of three literal values. |
| A 23-digit parcel id | SQLite integers are 64-bit, and Python raises OverflowError when asked to bind a larger number. |
Bound the path parameter to 1 through 2 to the power of 63 minus 1. |
The third row deserves a closer look, because a crash on a quote character is the visible edge of something worse. On a freshly restarted Step 4 server, create one parcel for each user, then ask for Alice’s parcels with a status filter of ' OR '1'='1. The last command sends that text URL-encoded.
curl -s -X POST http://127.0.0.1:8000/parcels -H "Authorization: Bearer alice-token" -H "Content-Type: application/json" -d '{"recipient": "Ada", "weight_grams": 300}'
curl -s -X POST http://127.0.0.1:8000/parcels -H "Authorization: Bearer bob-token" -H "Content-Type: application/json" -d '{"recipient": "Bo", "weight_grams": 300}'
curl -s -H "Authorization: Bearer alice-token" "http://127.0.0.1:8000/parcels?status=%27%20OR%20%271%27%3D%271"
{"recipient":"Ada","weight_grams":300,"service":"standard","id":1,"owner":"alice","status":"created","price_cents":500}
{"recipient":"Bo","weight_grams":300,"service":"standard","id":2,"owner":"bob","status":"created","price_cents":500}
[{"recipient":"Ada","weight_grams":300,"service":"standard","id":1,"owner":"alice","status":"created","price_cents":500},{"recipient":"Bo","weight_grams":300,"service":"standard","id":2,"owner":"bob","status":"created","price_cents":500}]
The first two lines are the new parcels. The third is the answer to Alice’s request, and it contains Bob’s parcel as well as her own. That is an SQL injection: the extra quote and the OR rewrote the query so that the owner condition no longer applied, and one user can read every user’s data. The SQL injection tutorial on this site covers the full fix. Schemathesis is not a security scanner, but a 500 on odd input is exactly the clue that sends you to look.
Apply the fix. It replaces the ParcelId definition, print_label, quote and list_parcels. Because ParcelId is shared by the three routes with an id in the path, one change covers all of them.
# app.py (replace ParcelId, print_label, quote, list_parcels)
ParcelId = Annotated[int, Path(ge=1, le=2**63 - 1)]
def print_label(recipient: str) -> bytes:
return recipient.upper().encode("ascii", errors="replace")
@app.get("/quote")
def quote(
weight_grams: Annotated[int, Query(ge=1, le=30000)],
zone: Annotated[int, Query(ge=1, le=6)] = 1,
service: Service = "standard",
) -> dict[str, int]:
return {"price_cents": price_cents(weight_grams, service, zone)}
@app.get("/parcels", response_model=list[Parcel])
def list_parcels(
user: Annotated[str, Depends(current_user)],
status: Status | None = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
sql, args = "SELECT * FROM parcels WHERE owner = ? AND deleted = 0", [user]
if status:
sql, args = sql + " AND status = ?", [*args, status]
with lock:
rows = db.execute(sql + " LIMIT ?", [*args, limit]).fetchall()
return [dict(r) for r in rows]
Restart uvicorn and replay the same four requests.
--- quote with zone 8
HTTP/1.1 422 Unprocessable Content | {"detail":[{"type":"less_than_equal","loc":["query","zone"],"msg":"Inp
server log: (nothing in the server log)
--- express parcel for Zo\u00eb
HTTP/1.1 201 Created | {"recipient":"Zoë","weight_grams":1,"service":"express","id":1,"owner"
server log: (nothing in the server log)
--- status filter with one quote
HTTP/1.1 422 Unprocessable Content | {"detail":[{"type":"literal_error","loc":["query","status"],"msg":"Inp
server log: (nothing in the server log)
--- a 23-digit parcel id
HTTP/1.1 422 Unprocessable Content | {"detail":[{"type":"less_than_equal","loc":["path","parcel_id"],"msg":
server log: (nothing in the server log)
The crashes are gone. The bad zone, the quote and the huge id are now refused with a 422, which is the answer the contract allows, and the express parcel for Zoë is created. Restart once more so the ids start at 1, and run the three injection commands again. The last one is refused too.
{"recipient":"Ada","weight_grams":300,"service":"standard","id":1,"owner":"alice","status":"created","price_cents":500}
{"recipient":"Bo","weight_grams":300,"service":"standard","id":2,"owner":"bob","status":"created","price_cents":500}
{"detail":[{"type":"literal_error","loc":["query","status"],"msg":"Input should be 'created', 'shipped' or 'delivered'","input":"' OR '1'='1","ctx":{"expected":"'created', 'shipped' or 'delivered'"}}]}
Run Schemathesis again.
Failures:
❌ ST401 API accepts requests without authentication: 1
❌ ST301 Deleted resource still available: 1
❌ ST205 Allow header mismatch: 2
❌ ST203 Missing required header accepted: 1
❌ ST201 Schema-violating request accepted: 1
Test cases:
1405 generated, 4 found 6 unique failures, 10 skipped
Seed: 1
======================================= 6 failures in 6.85s ========================================
Both ST101 and the leftover ST103 lines are gone: six failures remain, and the three codes for /reports/daily are the next target.
How many examples are enough?
The first run missed the 23-digit id, and that is normal. The baseline guide says it directly: “Data is generated at random, so a single run does not meet every failure your API has.” To see how much that matters, I took the Step 4 app, which still has all four crashes, and measured how often two of them were found, running one operation at a time for seeds 1 to 10 with -n (the maximum number of examples per operation) set to 100 (the default in the configuration reference), 300 and 1000. Each run used only the coverage and fuzzing phases and only the not_a_server_error check. This is the command, with the seed and the example count filled in by a loop:
schemathesis run http://127.0.0.1:8000/openapi.json --seed $seed -n $n --generation-database none --checks not_a_server_error --phases coverage,fuzzing --include-operation-id list_parcels_parcels_get --header "Authorization: Bearer alice-token"
| Examples per operation | Seeds with a 500 from the SQL-built status filter (GET /parcels) |
Seeds with a 500 from a 23-digit id (GET /parcels/{parcel_id}) |
|---|---|---|
| 100 (the default) | 3 of 10 (median 134 requests) | 6 of 10 (median 32 requests) |
| 300 | 9 of 10 (median 249 requests) | 10 of 10 (median 26 requests) |
| 1000 | 9 of 10 (median 287 requests) | 10 of 10 (median 26 requests) |
(For the second column the same command was used with --include-operation-id get_parcel_parcels__parcel_id__get.) Three things stand out. A single run at the default setting would have missed the SQL bug seven times out of ten. Tripling the examples helped a lot, but going from 300 to 1000 added nothing, and one seed in ten still missed the SQL bug at both sizes. And each run stops soon after it finds a failure, so the request counts stay far below the limit. So treat a green run as a statement that nothing was found with this seed and this budget, not as proof. A good routine is to pin the seed for pull request checks, so that failures are repeatable, and to run a longer job on a schedule with a fresh seed each time, printing the seed so that any failure can be replayed. These counts come from one small API, so expect your own numbers to differ.
Step 6: Fix the authentication gap (ST401, ST203, ST201)
Look back at the GET /reports/daily block from Step 3. It reported three codes for one request that had no Authorization header and still got a 200. Three different checks noticed the same bug.
ST401comes from theignored_authcheck. The documentation says that “when an operation declares authentication, Schemathesis tests whether the API accepts requests without credentials or with invalid credentials.” A 401, a 403 or a redirect counts as enforcement; a 200 does not.ST203comes frommissing_required_header, which expects a 401 when a requiredAuthorizationheader is missing.ST201comes fromnegative_data_rejection. A request with the header removed is invalid data according to the schema, and invalid data should be rejected.
The cause is in the code. The route uses HTTPBearer(auto_error=False) and a helper that turns a missing token into the user guest. FastAPI still publishes a bearer scheme for the route, so the schema says a token is required while the code says guests are welcome. A contract that promises protection the code does not provide is exactly the mismatch this tool exists to find. The fix is to use the same current_user dependency as every other route and to remove the helper and the optional scheme, which nothing else uses.
# app.py (replace daily_report; remove user_or_guest, bearer_optional)
@app.get("/reports/daily")
def daily_report(user: Annotated[str, Depends(current_user)]) -> dict[str, int]:
with lock:
total = db.execute("SELECT COUNT(*) FROM parcels WHERE deleted = 0").fetchone()[0]
return {"parcels": total}
Restart uvicorn and run the same command again.
Failures:
❌ ST301 Deleted resource still available: 1
❌ ST205 Allow header mismatch: 2
Test cases:
1514 generated, 3 found 3 unique failures, 12 skipped
Seed: 1
======================================= 3 failures in 6.93s ========================================
Three failures left: the delete bug and the two ST205 lines.
Step 7: Catch the delete bug with stateful testing (ST301)
Some bugs need a sequence of requests. The stateful testing explanation says that “the stateful phase chains operations with real response data”, so a read can target a parcel that really exists. It works out which operations feed which by analyzing the schema: POST /parcels returns an id, and three operations need a parcel_id. Links like these are what the run header counts as inferred (10 in this run). Here is the failure it found.
__________________________________________ Stateful tests __________________________________________
1. Test Case ID: IOd2qa
- ST301 Deleted resource still available
The API did not return a `HTTP 404 Not Found` response (got `HTTP 200 OK`) for a resource that was previously deleted.
The resource was deleted with `DELETE /parcels/59`
[200] OK:
`{"recipient":"NIL","weight_grams":166,"service":"standard","id":59,"owner":"alice","status":"created","price_cents":500}`
Reproduce with:
curl -X POST -H 'Authorization: [Filtered]' -H 'Content-Type: application/json' -d '{"recipient": "NIL", "weight_grams": 166}' http://127.0.0.1:8000/parcels
curl -X DELETE -H 'Authorization: [Filtered]' http://127.0.0.1:8000/parcels/59
curl -X GET -H 'Authorization: [Filtered]' http://127.0.0.1:8000/parcels/59
st replay IOd2qa
The reproduction is three curl commands, shrunk to the smallest chain that still fails: create a parcel, delete it, read it back. The API answers 200 with the parcel instead of 404. The cause is a soft delete. DELETE only sets deleted = 1, the list endpoint filters on that column, but the shared fetch helper used by GET does not. A test that looks at one operation at a time cannot see it. The fix is one line in fetch.
# app.py (replace fetch)
def fetch(parcel_id: int):
with lock:
return db.execute(
"SELECT * FROM parcels WHERE id = ? AND deleted = 0", (parcel_id,)
).fetchone()
Restart uvicorn and run again.
Failures:
❌ ST205 Allow header mismatch: 2
Test cases:
1351 generated, 2 found 2 unique failures, 12 skipped
Seed: 1
======================================= 2 failures in 7.06s ========================================
Only ST205 remains. The deleted parcel now answers 404, and a second DELETE of the same id answers 404 too, because fetch no longer finds it.
Step 8: Add a second user and catch the IDOR (ST402)
The run is clean apart from ST205, yet the app still has a serious hole: any signed-in user can read any parcel. GET /parcels/{parcel_id} checks that you have a valid token, but never checks that the parcel is yours. The OWASP API Security project calls this class of bug broken object level authorization and explains it like this: “Object level authorization is an access control mechanism that is usually implemented at the code level to validate that a user can only access the objects that they should have permissions to access.” The IDOR tutorial on this site goes deep on fixing it.
Why did the run not flag it? With one token, every object belongs to “you”. To notice that Bob can read Alice’s parcel, the tool needs both users. Schemathesis has a check for this, object_level_authorization, which the checks reference describes as one that “verifies that one user cannot read an object another user owns (OWASP API1, Broken Object Level Authorization)”. The guide adds that “Schemathesis tests a read only when it knows which user owns the object”, which it learns from objects a user created or from lists a user requested.
You give it the users in a small JSON file, in the Web Fuzzing Commons format the docs describe, and then list which users must stay private from each other as peers in a second configuration file. Create auth.json and peers.toml.
{
"auth": [
{"name": "alice", "fixedHeaders": [{"name": "Authorization", "value": "Bearer alice-token"}]},
{"name": "bob", "fixedHeaders": [{"name": "Authorization", "value": "Bearer bob-token"}]}
]
}
seed = 1
[generation]
database = "none"
[auth.wfc]
path = "auth.json"
peers = ["alice", "bob"]
The guide notes that you cannot combine [auth.wfc] with [auth.openapi.*], which is why this is a separate file and not an addition to schemathesis.toml. Point Schemathesis at it with the global --config-file option, which goes before the word run.
schemathesis --config-file peers.toml run http://127.0.0.1:8000/openapi.json
_____________________________________ GET /parcels/{parcel_id} _____________________________________
1. Test Case ID: NCztge
- ST402 Object-level authorization bypass
`bob` received the `Parcel` that `alice` owns
Owner: GET /parcels/2 as alice -> 200
Peer: GET /parcels/2 as bob -> 200 (equivalent body)
[200] OK:
`{"recipient":"0","weight_grams":1,"service":"standard","id":2,"owner":"alice","status":"created","price_cents":500}`
Reproduce with:
# as bob (peer)
curl -X GET -H 'Authorization: [Filtered]' http://127.0.0.1:8000/parcels/2
st replay NCztge
Schemathesis learned that parcel 2 belongs to Alice, read it as Alice, then repeated the same GET as Bob and got the same body with a 200. The check passes when the other peer gets different values, only an id, the same body an anonymous request gets (so the object is public), or the object through a list the peer may see. None of those applied. It also says “It never repeats writes”, so it covers reads only. Our PATCH and DELETE already check the owner, but for writes in your own API you need your own tests.
The fix is to treat someone else’s parcel exactly like a missing one. Answering 404 instead of 403 also avoids telling an attacker which ids exist, and it matches what PATCH and DELETE already do.
# app.py (replace get_parcel)
@app.get("/parcels/{parcel_id}", response_model=Parcel)
def get_parcel(parcel_id: ParcelId, user: Annotated[str, Depends(current_user)]):
row = fetch(parcel_id)
if row is None or row["owner"] != user:
raise HTTPException(status_code=404, detail="Parcel not found")
return dict(row)
Restart uvicorn and run the same two-user command again.
Failures:
❌ ST205 Allow header mismatch: 2
Test cases:
1377 generated, 2 found 2 unique failures, 38 skipped
Seed: 1
======================================= 2 failures in 6.13s ========================================
The ST402 failure is gone. One warning from the guide is worth remembering: if you forget the peers, the check does not run at all. “No peers under [auth.wfc]: the check does not run at all”, it says, so a clean run without that setting says nothing about IDOR. That is what the run at the end of Step 7 showed: nothing left but ST205, on an app where any user could read any parcel.
Step 9: Decide what to do about ST205 (Allow header mismatch)
Two failures have been on screen since Step 4. Here is one of them.
___________________________________ OPTIONS /parcels/{parcel_id} ___________________________________
1. Test Case ID: Yvdcfa
- ST205 Allow header mismatch
`Allow: GET` does not match the schema. Missing documented methods: DELETE, PATCH
List exactly the methods this resource supports in `Allow`
[405] Method Not Allowed:
`{"detail":"Method Not Allowed"}`
Identity: alice (reproduce with --auth-wfc-user alice)
Reproduce with:
curl -X OPTIONS -H 'Authorization: [Filtered]' http://127.0.0.1:8000/parcels/1
st replay Yvdcfa
The check sends an OPTIONS request and compares the Allow header in the answer with the methods the schema documents for the path. The schema says /parcels/{parcel_id} supports GET, PATCH and DELETE, but the server answered that only GET is allowed. The tool explains its own failure code, and its text matches the situation exactly.
ST205 Allow header mismatch
checks allow_header_conformance
`Allow` in the `OPTIONS` response lists different methods than the schema declares for the path.
CORS middleware or a gateway that sends one `Allow` for every path causes this: fix it there, or
remove `options` from `phases.coverage.unexpected-methods`.
`--exclude-checks allow_header_conformance` turns it off.
This is not a bug in your routes. FastAPI registers one route per HTTP method, and Starlette builds the Allow header from only the first route that matches the path. In Starlette 1.9.0, the router keeps the first partial match (routing.py, lines 701 to 703) and Route.handle builds the header from that one route’s methods (line 277). A tiny experiment shows the effect: the same three routes, added in two different orders.
# allow_probe.py: which methods does the Allow header list when FastAPI registers one route per method?
from fastapi import FastAPI
from fastapi.testclient import TestClient
HANDLERS = {"GET": lambda: {"ok": 1}, "POST": lambda: {"ok": 2}, "DELETE": lambda: {"ok": 3}}
def build(order):
app = FastAPI()
for method in order:
app.add_api_route("/things", HANDLERS[method], methods=[method])
return app
for order in (["GET", "POST", "DELETE"], ["DELETE", "POST", "GET"]):
reply = TestClient(build(order)).options("/things")
print(f"routes added in the order {order}: OPTIONS -> {reply.status_code}, Allow: {reply.headers.get('allow')}")
routes added in the order ['GET', 'POST', 'DELETE']: OPTIONS -> 405, Allow: GET
routes added in the order ['DELETE', 'POST', 'GET']: OPTIONS -> 405, Allow: DELETE
Whichever method was registered first is the only one listed, and your own run should print the same two lines. You have three honest options: write your own OPTIONS handling, switch the check off with --exclude-checks allow_header_conformance, or accept the finding and record it so it does not block anyone. Our API does not advertise OPTIONS and clients do not depend on it, so we accept it. In the pytest file in the next step we switch the check off, and in CI (Step 11) we record the two failures in a baseline, which also shows how a baseline works.
Step 10: Run the same checks inside pytest
The command line is the best way to run all four phases, but a pytest file gives you checks that run in your normal test suite, with no server to start. Create test_api.py.
# test_api.py
import schemathesis
from app import app
schema = schemathesis.openapi.from_asgi("/openapi.json", app)
schema.config.checks.update(excluded_check_names=["allow_header_conformance"])
@schema.parametrize()
def test_api(case):
case.call_and_validate()
from_asgi loads the schema straight from your FastAPI object and calls the app in the same process, so there is no network and no uvicorn. @schema.parametrize() turns test_api into one test per operation, and call_and_validate() sends each generated request and runs all the checks. The settings come from the same schemathesis.toml as before, which is why the token, the seed and the example database setting do not appear in the test. The one line under the schema excludes ST205, for the reason from Step 9. Run it on the original app first (copy app_original.py over app.py for a moment) and then on the fixed one.
pytest -q --tb=no test_api.py
.FFFFFFF [100%]
=== short test summary info ===
FAILED test_api.py::test_api[GET /quote] - KeyError: 8
FAILED test_api.py::test_api[POST /parcels] - + Exception Group Traceback (most recent call last):
FAILED test_api.py::test_api[GET /parcels] - + Exception Group Traceback (most recent call last):
FAILED test_api.py::test_api[GET /parcels/{parcel_id}] - + Exception Group Traceback (most recent call last):
FAILED test_api.py::test_api[PATCH /parcels/{parcel_id}] - + Exception Group Traceback (most recent call last):
FAILED test_api.py::test_api[DELETE /parcels/{parcel_id}] - + Exception Group Traceback (most recent call last):
FAILED test_api.py::test_api[GET /reports/daily] - + Exception Group Traceback (most recent call last):
7 failed, 1 passed in 1.55s
........ [100%]
8 passed in 6.23s
On the broken app, seven of the eight operations fail in about 1.5 seconds. Notice the first failure line: KeyError: 8. In this mode a crash in your code reaches the test as the original Python exception, not as a bare 500 response, so the traceback points at the offending line. On the fixed app, all eight pass.
Two limits apply. First, in the words of the pytest tutorial, “stateful tests are a separate test class built with schema.as_state_machine()”, so this file does not run the stateful phase and would not have found the delete bug from Step 7. Keep the command line run in CI for that. Second, there is a trap in how you send the token. It is tempting to write case.call_and_validate(headers={"Authorization": "Bearer alice-token"}). I tried it on the fixed app, with the token not in the configuration file.
...F.... [100%]
=== short test summary info ===
FAILED test_api_header.py::test_api[GET /parcels] - + Exception Group Traceback (most recent call last):
1 failed, 7 passed in 5.19s
One test fails with ST201, and the report says the invalid part of the request was the missing Authorization header, yet the printed curl line includes that header. Those two facts point to one explanation: Schemathesis generated a case with the header deliberately left out, to see whether the API rejects it, and the explicit headers= argument added it back, so the server accepted the request, as it should for a request with a valid token. Keep credentials in the configuration file, or use a registered authentication class, so Schemathesis stays in control of when the header is present.
Step 11: Put it in CI: exit codes, a baseline, and reports
A tool that only prints is not a safety net. The CI/CD guide says to “fail the CI job on a non-zero exit code”, and defines the codes: 0 when all checks passed, 1 when at least one check failed or a bug was reported, 2 when Schemathesis could not run (a configuration or schema error, or nothing was tested), and 130 when the run was interrupted. For repeatable pull request checks it recommends --generation-deterministic or --seed.
That leaves the two ST205 failures, which would turn every build red. A baseline solves that. In the words of the baseline guide, “a baseline records the ones you are not fixing yet, so CI fails only on failures that are new.” Create the file by running once with --baseline and a path that does not exist yet.
schemathesis run http://127.0.0.1:8000/openapi.json --baseline schemathesis-baseline.json
Failures:
❌ ST205 Allow header mismatch: 2
Baseline:
Known failures: 0
Recorded: 2
Test cases:
1527 generated, 2 found 2 unique failures, 12 skipped
Seed: 1
======================================= 2 failures in 8.16s ========================================
The recording run itself still fails (exit code 1), because it found failures, but it wrote schemathesis-baseline.json with two entries. Commit that file. Then tell Schemathesis to load it on every run by adding one line to schemathesis.toml. In TOML, a key without a table must come before the first [table] header. I first appended the line at the bottom of the file, and Schemathesis stopped with an error because the key had landed inside [auth.openapi.HTTPBearer], where baseline is not allowed.
seed = 1
baseline = "schemathesis-baseline.json"
[generation]
database = "none"
[auth.openapi.HTTPBearer]
bearer = "alice-token"
Run again. The header now reads Baseline: schemathesis-baseline.json / 2 entries.
Baseline:
Known failures: 2
Test cases:
1508 generated, 1508 passed, 12 skipped
Seed: 1
===================================== No issues found in 7.05s =====================================
The run is green, exit code 0, because the only failures are the recorded ones. Now a regression. Suppose someone changes the quote endpoint back to Query(ge=1, le=9) while the rate table still stops at 6.
Failures:
❌ ST101 Server error: 1
❌ ST103 Undocumented status code: 1
Baseline:
Known failures: 2
Test cases:
1397 generated, 1 found 2 unique failures, 12 skipped
Seed: 1
======================================= 2 failures in 6.55s ========================================
The two known failures stay quiet, and the new ST101 and ST103 fail the run with exit code 1. That is the whole point of a baseline: it records the failures you accept today and turns every future regression red. It is also a promise to revisit. The guide’s --baseline-prune option drops entries you have since fixed, and entries can carry an expires date.
In a pipeline, the whole step is three commands. --wait-for-schema makes Schemathesis wait up to the given number of seconds for the server to answer, so you do not need a sleep. This is the sequence I ran with the final app.
uvicorn app:app --port 8000 &
schemathesis run http://127.0.0.1:8000/openapi.json --wait-for-schema 10
echo "exit code: $?"
Baseline:
Known failures: 2
Test cases:
1508 generated, 1508 passed, 12 skipped
Seed: 1
===================================== No issues found in 8.05s =====================================
exit code: 0
For a build log that other tools can read, add --report junit,json. Each report goes into a schemathesis-report folder, in files named with a timestamp.
json-20261019T161759Z.json
junit-20261019T161759Z.xml
Other formats exist too (the command help lists junit, vcr, har, ndjson, json, wfc, pytest, allure and html). Add .schemathesis/, .hypothesis/ and schemathesis-report/ to your .gitignore, because Schemathesis creates them in the folder it runs from.
Step 12: See what a clean run still cannot tell you
At this point the run is green and the pytest file passes. Is the API correct? No. Two defects remain, and neither one breaks the contract in a way a schema-driven tool can see. The first is a pricing rule. Suppose the product rule is this: charge 40 cents for every started 500 grams on top of the zone rate. The code computes weight_grams // 500, which rounds down, so a 1 g letter is charged for zero extra units. The second is a mass assignment bug. PATCH /parcels/{parcel_id} accepts a status field, so a customer can mark their own parcel as delivered, although only the carrier’s systems should be able to. Both responses are perfectly valid JSON of the right shape. A parcel that costs 5.00 instead of 5.40 is a fine response, and status is a documented field of the request, so sending it is valid input.
The cure is ordinary example tests that encode the rules. Create test_rules.py next to test_api.py.
# test_rules.py
from fastapi.testclient import TestClient
from app import app, price_cents
client = TestClient(app)
ALICE = {"Authorization": "Bearer alice-token"}
def test_price_is_charged_per_started_500_grams():
assert price_cents(1, "standard") == 540
assert price_cents(500, "standard") == 540
assert price_cents(501, "standard") == 580
def test_owner_cannot_mark_own_parcel_delivered():
created = client.post("/parcels", json={"recipient": "Ada", "weight_grams": 400}, headers=ALICE)
parcel_id = created.json()["id"]
client.patch(f"/parcels/{parcel_id}", json={"status": "delivered"}, headers=ALICE)
assert client.get(f"/parcels/{parcel_id}", headers=ALICE).json()["status"] == "created"
On the Step 8 version of the app, both tests fail with real assertion errors.
FF [100%]
=== FAILURES ===
____________________________________________________ test_price_is_charged_per_started_500_grams ____________________________________________________
test_rules.py:11: in test_price_is_charged_per_started_500_grams
assert price_cents(1, "standard") == 540
E AssertionError: assert 500 == 540
E + where 500 = price_cents(1, 'standard')
____________________________________________________ test_owner_cannot_mark_own_parcel_delivered ____________________________________________________
test_rules.py:20: in test_owner_cannot_mark_own_parcel_delivered
assert client.get(f"/parcels/{parcel_id}", headers=ALICE).json()["status"] == "created"
E AssertionError: assert 'delivered' == 'created'
E
E - created
E + delivered
=== short test summary info ===
FAILED test_rules.py::test_price_is_charged_per_started_500_grams - AssertionError: assert 500 == 540
FAILED test_rules.py::test_owner_cannot_mark_own_parcel_delivered - AssertionError: assert 'delivered' == 'created'
2 failed in 0.31s
Now fix both defects. Rounding up is a one-line change, and the mass assignment fix removes status from the patch model and refuses unknown fields. The mass assignment tutorial covers that bug class in depth.
# app.py (replace price_cents, ParcelPatch)
class ParcelPatch(BaseModel):
model_config = {"extra": "forbid"}
recipient: str | None = Field(default=None, min_length=1, max_length=80)
def price_cents(weight_grams: int, service: str, zone: int = 1) -> int:
started_halves = -(-weight_grams // 500)
base = ZONE_RATES[zone] + started_halves * 40
return base * 2 if service == "express" else base
Run both test files, then Schemathesis once more.
.. [100%]
2 passed in 0.20s
Failures:
❌ ST205 Allow header mismatch: 2
Test cases:
1527 generated, 2 found 2 unique failures, 12 skipped
Seed: 1
======================================= 2 failures in 8.01s ========================================
The example tests pass, and Schemathesis reports only the accepted ST205. The two tools answer different questions. Schemathesis asks whether the server keeps the promises written in its schema, on inputs you did not think of. Example tests ask whether the product rules you did think of hold. For rules that should hold for every input, not only a few, the Hypothesis tutorial shows how to write them as properties.
The scorecard
| Defect | Check | How it showed up | Fixed in |
|---|---|---|---|
| Status codes the code returns but the schema omits | ST103 |
First run, 12 failures | Step 4 |
| Zone 7 to 9 accepted, no rate | ST101 |
First run (single-operation phases) | Step 5 |
| Non-ASCII name crashes the label printer | ST101 |
First run, stateful phase | Step 5 |
| Status filter built into SQL text (also an SQL injection) | ST101 |
First run, stateful phase, through a NUL character in the filter | Step 5 |
| 23-digit parcel id overflows SQLite | ST101 |
Not found by seed 1; 6 of 10 seeds at 100 examples | Step 5 |
| Daily report accepts requests without a token | ST401, ST203, ST201 |
First run | Step 6 |
| Deleted parcel can still be read | ST301 |
First run, stateful phase | Step 7 |
| Any user can read any parcel (IDOR) | ST402 |
Only with two users configured | Step 8 |
Allow header lists one route’s methods |
ST205 |
After Step 4 (or the first run with --continue-on-failure) |
Accepted in a baseline |
| Price rounds down instead of up | none | Not found by Schemathesis; example test | Step 12 |
Customer can set status (mass assignment) |
none | Not found by Schemathesis; example test | Step 12 |
Check that everything works end to end
Start the final app and run all of these. Each should match the result in the table.
schemathesis run http://127.0.0.1:8000/openapi.jsonwith the baseline inschemathesis.tomlexits with code 0 and prints “No issues found”.schemathesis --config-file peers.toml run http://127.0.0.1:8000/openapi.jsonreports onlyST205(and exits 1, since this run does not use the baseline).pytest -qin the project folder runs both test files and passes.
.......... [100%]
10 passed in 6.41s
| After | Failures with seed 1 |
|---|---|
| Step 3, first run | 19 |
| Step 4, documented responses | 12 |
| Step 5, server errors fixed | 6 |
| Step 6, authentication fixed | 3 |
| Step 7, delete fixed | 2 (ST205 only) |
| Step 8, two users: found, then fixed | 3, then 2 |
| Step 12, rules fixed | 2 (ST205 only, accepted in the baseline) |
Common mistakes and gotchas
Aiming at a real environment
The run creates, changes and deletes data and sends malformed input at high volume. Use a local or disposable test environment with fake data, and keep real credentials out of the configuration file (the docs show how to read values from environment variables).
Trusting a green run too much
A clean run means nothing was found with this seed and this budget. The table in Step 5 shows what that is worth, and Step 8 shows a bug class that is invisible without extra setup.
Forgetting the example database
The default stores failing inputs under .hypothesis/examples and replays them. If two runs with the same seed differ, or an old failure keeps appearing, check that folder. We turned it off for this tutorial so the outputs would match.
Comparing failure counts between runs
Counts mix codes, bugs and operations, and a run that stops early on an operation hides later checks. Compare which codes and which operations fail.
Running from a subfolder
When I ran from a subfolder with no configuration file of its own, Schemathesis picked up the schemathesis.toml of the parent folder (the seed from that file showed up in the output). The header of every run has a Configuration: line that names the file in use, so check it when a result surprises you.
Putting a baseline key under a TOML table
Top-level keys such as seed and baseline belong above the first [table], or Schemathesis reports a configuration error and exits with code 2.
Where to go next
- Declare error responses per route, and give your schema example values (for instance with the
examplesoption of a Pydantic field), because the examples phase sends theexampleandexamplesvalues it finds there. - Use the
schemathesis fuzzcommand, described in the help as “Run continuous API fuzzing”, for a long scheduled run with a fresh seed. - Add
--max-response-timeto turn slow responses into failures (the checks reference lists it asST112). - Read the authentication guide for login flows with expiring tokens, and the stateful guide for writing OpenAPI links by hand when inference misses a connection.
- Apply the same approach to a second class of API bugs: the secure file uploads tutorial shows what a hostile multipart body can do, which a schema-driven run will not try.
Sources and method
- Schemathesis documentation: checks, object-level authorization, stateful testing, data generation, baseline, CI/CD, authentication, pytest tutorial and configuration reference
- Schemathesis on PyPI (version 4.32.1, uploaded October 19, 2026, requires Python 3.11 or newer)
- FastAPI documentation, Additional Responses in OpenAPI
- OWASP API Security Top 10 2023, API1: Broken Object Level Authorization
- Starlette 1.9.0,
routing.py
Method. Everything was run on Windows 11 with Python 3.13.14, Schemathesis 4.32.1 (with Hypothesis 6.170.2), FastAPI 0.149.0, Starlette 1.9.0, Uvicorn 0.54.0, pytest 9.1.1 and curl 8.21. With seed 1 and the example database off, three consecutive first runs gave identical summaries. The listings in this article were rebuilt from the page into a clean folder and run again. The failure counts, codes and exit codes matched; timings and generated test case IDs differ from run to run. The seed experiment in Step 5 used ten seeds per setting on one small API. The lab does not test Linux or macOS, GraphQL, authentication flows with login endpoints, the fuzz command, or APIs with OpenAPI links written by hand.








No Comment! Be the first one.