TRENDING
Rows of red climbing helmets, carabiners and harness straps laid out on the ground at a ropes course
October 21, 2026
How to Build a Safe AI Agent Harness in Python With Tool Validation, a File Sandbox, and Run Limits
A thin transparent polymer test specimen stretched between the metal grips of a tensile testing machine
October 21, 2026
Ai2’s FairStress Turns Fairness Testing Into a Pressure Test, but the Released Files Do Not Match the Report
A vintage Banda Model Ten spirit duplicator, a hand-cranked paper copying machine, shown with its instruction book on a white background
October 21, 2026
An Anonymous Comment Can Hand Duplicator a Payload That Fires at Migration Time, and Under a Third of Free Installs Run the Fix
Colorful horizontal layers of sedimentary rock exposed in a cliff face at Makhtesh Ramon in Israel, with two desert shrubs in the foreground
October 21, 2026
How to Build an LSM-Tree Key-Value Store in Python With a Write-Ahead Log, SSTables, Bloom Filters, and Compaction
A broadcast studio teleprompter with script lines shown mirrored on its lower monitor and reflected in the glass in front of a camera lens
October 21, 2026
WhatsApp’s Leaked Chatbot Notes Turn a Help Page Into Two Documents, and the Hidden One Told the Bot Not to Recommend a Regulator
21 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
An out-of-order sign standing in an airport sink basin, with a title, a friendly explanation and a Spanish translation, like the members of an error response
How to Return RFC 9457 Problem Details From a FastAPI API and Stop Error Responses From Leaking Secrets
October 21, 2026
Heavily corroded cogwheels and a rusted ring gear inside an abandoned sulphur factory beside the sea on White Island
Cloudflare’s tsrs Turns an Agent-Written Compiler Port Into a Differential-Testing Bet, and the 8x Headline Depends on the Baseline
October 21, 2026
A black public warning siren with a wide dish-shaped horn on a short pole against a clear blue sky
PHP Warns of Two Critical Flaws and Sets a 14:00 UTC Patch, but the Leak, the Score and the Exploitation Reports Do Not Agree
October 21, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 255 Posts
News 258 Posts
Learning Hub 228 Posts
Home/Learning Hub/How to Fuzz-Test a FastAPI API With Schemathesis and Find Server Errors, Missing Auth, and IDOR From Its OpenAPI Schema
Learning Hub

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...

October 19, 2026 36 Min Read
20

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 = 1 fixes 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 token alice-token wherever the schema declares a security scheme with that name. HTTPBearer is the name FastAPI gives to the scheme of its HTTPBearer class, (you can check it with app.openapi()["components"]["securitySchemes"], which lists HTTPBearer). 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 example and examples values 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 /parcels feeds a later GET /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.

  • ST401 comes from the ignored_auth check. 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.
  • ST203 comes from missing_required_header, which expects a 401 when a required Authorization header is missing.
  • ST201 comes from negative_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.

  1. schemathesis run http://127.0.0.1:8000/openapi.json with the baseline in schemathesis.toml exits with code 0 and prints “No issues found”.
  2. schemathesis --config-file peers.toml run http://127.0.0.1:8000/openapi.json reports only ST205 (and exits 1, since this run does not use the baseline).
  3. pytest -q in 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 examples option of a Pydantic field), because the examples phase sends the example and examples values it finds there.
  • Use the schemathesis fuzz command, described in the help as “Run continuous API fuzzing”, for a long scheduled run with a fresh seed.
  • Add --max-response-time to turn slow responses into failures (the checks reference lists it as ST112).
  • 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.

Tags:

API SecurityFastAPIFuzzingProperty-Based TestingpytestPythonSchemathesis

Share

Colorful wooden toy blocks stacked into a tower with an arch, a tall blue pillar and a red triangle against a gray-blue wall
Previous Post

MIT’s Desert-Island Claude Experiment Turns Idle Agent Time Into a Memory-File Test, and Its Keep-Going Hook Fired 68,193 Times

Exterior of Building 1 on the Department of Homeland Security’s St. Elizabeths campus in Washington, D.C., a red brick building with a central tower and flagpoles under a blue sky
Next Post

DHS Orders Federal Civilian Agencies to Remove DeepSeek in an Order That Carries a July 24 Release Date

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
21 Oct
How to Return RFC 9457 Problem Details From a FastAPI API and Stop Error Responses From Leaking Secrets
21 Oct
Cloudflare’s tsrs Turns an Agent-Written Compiler Port Into a Differential-Testing Bet, and the 8x Headline Depends on the Baseline
Trending
October 21, 2026
How to Return RFC 9457 Problem Details From a FastAPI API and Stop Error Responses From Leaking Secrets
October 21, 2026
Cloudflare’s tsrs Turns an Agent-Written Compiler Port Into a Differential-Testing Bet, and the 8x Headline Depends on the Baseline
October 21, 2026
PHP Warns of Two Critical Flaws and Sets a 14:00 UTC Patch, but the Leak, the Score and the Exploitation Reports Do Not Agree
October 20, 2026
How to Build a Safe AI Agent Harness in Python With Tool Validation, a File Sandbox, and Run Limits
October 20, 2026
Ai2’s FairStress Turns Fairness Testing Into a Pressure Test, but the Released Files Do Not Match the Report
October 20, 2026
An Anonymous Comment Can Hand Duplicator a Payload That Fires at Migration Time, and Under a Third of Free Installs Run the Fix

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026