cd /news/developer-tools/phase-8-making-it-trustworthy-harden… · home topics developer-tools article
[ARTICLE · art-98797] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Phase 8 — Making It Trustworthy: Hardening a FastAPI App with an Audit, a Test Net, and a Logging Bug I Typed Twice

A developer completed Phase 8 of hardening a FastAPI application, focusing on security and reliability rather than new features. The work included adding a public /health endpoint, auditing the API for leaks, implementing a global exception handler, writing a pytest suite, adding pagination, and sweeping git history for secrets. The audit found zero leaks, and most bugs were in the terminal, not the code.

read16 min views1 publishedAug 16, 2026

Phase 7 gave the app a brain. Phase 8 was about making it trustworthy — the unglamorous gate between "works on my machine" and "safe to put a URL in front of."

No new features. Instead: a security audit that probed my own API for leaks, a global exception handler so unexpected errors stop leaking internals, a five-test pytest suite that turns every manual check into a permanent one, and a git-history secrets sweep. Plus the war story — a logging placeholder I fat-fingered the same way twice, a test that silently never ran, and a login that failed because of one capital letter.

Spoiler: the app had zero leaks. Almost every bug this phase was in the terminal, not the code.

/health

endpointPhase 7b ended with the seam holding: a category column, a rules engine, and a Gemini-backed LLMCategorizer

living behind the same Categorizer

contract — validated, cached, and with a fallback that was finally loud about failing.

I closed that post nagging at three things: the cache dies on restart, the model name is a drifting alias, and my free tier can't afford to be the default. All real. All enhancements.

But there was a bigger question I'd been walking past: is this thing actually safe to deploy? I had auth, I had owner-scoped queries, I believed users were isolated — but belief isn't proof. Phase 8 is where I stop believing and start verifying.

The temptation was to jump straight to "add security stuff." I didn't. You can't harden what you haven't observed, so I ordered the work from lowest risk and highest evidence outward:

Step What Why this order
1 A /health endpoint
Purely additive warm-up; also the thing my host will ping in Phase 9
2 Audit the real API for leaks Get evidence before writing a single fix — don't guess what leaks
3 Global exception handler The one gap the audit couldn't test: the unexpected 500
4–5 A pytest suite Turn every manual proof into a permanent, re-runnable one
6 Pagination Put a ceiling on an unbounded list before it becomes a problem
7 Secrets / git-history sweep The one item with real downside if skipped before going public

The principle from Phase 7 still drives it: observe first, then change one thing at a time. When something breaks, I want to know it broke in the thing I just touched.

/health

endpoint Every real route in my app is auth-gated behind get_current_user

. That's correct for expenses — but a monitoring system can't log in. So I need exactly one deliberately public, side-effect-free route that answers "are you alive?" with a fast, boring 200

.

@app.get("/health")
def health_check():
    return {"status": "ok"}
Idea Why it matters
No Depends , no auth
The absence is the point — a monitor hits it without a token
No DB call It answers "is the process up," not "is every subsystem healthy." A deeper check is a conscious later step
Returns a plain dict
FastAPI serializes it to JSON automatically — no response_model needed for something this trivial

Additive, zero risk, and it's the exact URL Phase 9's host will hit to confirm the deploy is up before I even try to log in. Good warm-up.

This is the part I'd skipped in every previous project: actually attacking my own endpoints and recording what they return. Not "I think it 404s" — curl it and read the status line.

I grabbed a token once, stashed it in a shell variable, and fired four deliberately-bad requests. Here's the evidence table that came out:

# Probe Expected Actual Verdict
1 POST expense, negative amount 422 422 Input validation holds
2 GET /expenses , garbage token
401 401 Auth boundary holds
3 GET a non-existent expense id 404 404 Not-found path is clean
4 GET another user's expense id 404 404 Cross-user isolation holds

Two things I want to call out, because they're design wins, not luck:

Probe #1 dies at the schema, not the database. My ExpenseCreate

declares amount

with gt=0

, so Pydantic rejects a negative amount before my route body ever runs. Nothing gets written. Validation at the edge means the dangerous code never executes.

Probe #4 returns 404, not 403 — on purpose. My query filters by both id

and user_id == current_user.id

, so a row I don't own comes back as None

→ 404. A 403 Forbidden

would be the wrong choice here: it would confirm "this expense exists, you're just not allowed to see it," which leaks the record's existence. 404

says "nothing here for you" and reveals nothing.

The candid bit: the "raw stack trace" fear I'd been carrying is mostly unfounded on FastAPI. It doesn't put tracebacks in the HTTP response by default — it returns a generic 500 and logs the trace server-side. Tracebacks only leak into the body if you run with debug on. So the real production job isn't "hide the trace," it's "make sure debug stays off, and add a net for the unexpected." Which is Step 3.

The audit found zero leaks — but it also couldn't test one thing, because nothing in my app currently throws an unhandled error. Production code eventually will. When it does, I want a deliberate, consistent response — not whatever Starlette's default is.

FastAPI already handles two error families: my intentional HTTPException

s (401/404/409) and validation errors (422). What it doesn't handle is the unexpected — a bug, a None

where I assumed a value. A catch-all lets me log the full traceback server-side with a correlation id, and return a clean generic body to the client.

@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
    error_id = uuid.uuid4().hex[:8]
    logger.exception(
        "Unhandled error [%s] on %s %s", error_id, request.method, request.url.path
    )
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error", "error_id": error_id},
    )
Concept What it does
@app.exception_handler(Exception)
Catches only what a more specific handler didn't — my HTTPException s and 422s keep their exact status codes
logger.exception(...)
Logs at ERROR level with the full traceback — the server-side record
uuid4().hex[:8]
A short correlation id, printed in both the log and the response
Generic {"detail", "error_id"} body
The client sees a reference code to quote to support — never internals

The critical nuance is that first row. A handler on the broad Exception

type does not swallow my deliberate HTTPException

s — if it did, every 404 would turn into a 500. So the design is a floor under the unexpected, not a replacement for my existing errors.

And I proved it — not by faith, by a throwaway /debug/boom

route that raised on demand, watched the handler catch it, then deleted the route. The 404 regression check afterward confirmed the catch-all left my intentional errors alone. (Getting that log line to actually format is its own war story below.)

Everything I proved in Step 2 with curl — the 422, the 401, the 404, the isolation — evaporates the moment I change code. A test turns a one-time proof into a permanent guarantee, and it's the single most credible signal to a stranger reading the repo that the app actually works.

FastAPI's TestClient

calls the app in-process — no uvicorn, no network, fast and isolated. I proved the harness with one trivial /health

test first (prove the tooling before you trust the result), then added the real ones.

The interesting piece is testing authenticated paths without a live login, using dependency_overrides

— and the trap that comes with it:

@pytest.fixture
def as_authenticated_user():
    def _fake_user():
        return User(id=1, name="Test User", email="test@example.com", hashed_password="x")
    app.dependency_overrides[get_current_user] = _fake_user
    yield
    app.dependency_overrides.clear()
Concept What it does
dependency_overrides
Swaps a real dependency for a fake — auth is satisfied, the route runs
A fixture, not a global
The trap: a global override poisons every test, silently breaking the 401 test
yield split
Everything before is setup, everything after is teardown — the .clear() is what keeps tests isolated

The proof that the teardown works: after adding the fixture, my test_expenses_requires_auth

test still returned 401. If the override had leaked, it would've gotten the fake user and failed. Isolation intact.

The highest-value test is the one thing I'd only proven by hand: cross-user isolation. But it writes to the database, so it can't touch my real expenses.db

. That means a proper test-database fixture — and the trickiest concept of the whole phase.

@pytest.fixture
def test_db():
    engine = create_engine(
        "sqlite://",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(bind=engine)
    TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)

    def _override_get_db():
        db = TestingSessionLocal()
        try:
            yield db
        finally:
            db.close()

    app.dependency_overrides[get_db] = _override_get_db
    seed = TestingSessionLocal()
    try:
        yield seed
    finally:
        seed.close()
        app.dependency_overrides.clear()
        Base.metadata.drop_all(bind=engine)

The StaticPool

line is the one to internalize. TestClient

runs requests in a thread pool, and a normal in-memory SQLite database is per-connection — a second connection sees an empty database, so my seeded rows would vanish. poolclass=StaticPool

forces SQLAlchemy to reuse one single connection for everything, so the schema and seeded data persist across request threads. Without it, this test fails in a baffling way.

The test itself seeds two users and one expense owned by user2, then makes the same request as each identity:

def test_cross_user_isolation(test_db):

    app.dependency_overrides[get_current_user] = lambda: User(id=u2_id)
    owner = client.get(f"/expenses/{expense_id}")
    assert owner.status_code == 200      # the control

    app.dependency_overrides[get_current_user] = lambda: User(id=u1_id)
    other = client.get(f"/expenses/{expense_id}")
    assert other.status_code == 404      # the guarantee

The 200

assertion is the control, and it's what stops this from being a false pass. It proves the row genuinely exists and is readable — so the following 404

can only mean my owner-scoped query hid it from a different user. Same id, two identities, two outcomes. That's the isolation guarantee, now permanent.

(This test almost didn't run at all — see the war story.)

My list_expenses

was returning every row for the user with no ceiling. Fine at 12 rows, a latent problem at 10,000. Two query params fix it — with validation at the edge:

@app.get("/expenses", response_model=List[ExpenseRead])
def list_expenses(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
    limit: int = Query(default=50, ge=1, le=100),
    offset: int = Query(default=0, ge=0),
):
    expenses = db.scalars(
        select(Expense)
        .where(Expense.user_id == current_user.id)
        .order_by(Expense.spent_on.desc(), Expense.id.desc())
        .limit(limit)
        .offset(offset)
    ).all()
    return expenses

The non-obvious part: offset/limit is meaningless without a deterministic ORDER BY. SQLite makes no promise about row order unless you ask, so "skip 20, take 20" could overlap or drop rows between requests if the order shifts. Pagination and ordering are a package deal — hence the

order_by

.The actual hardening is le=100

: a caller asking for limit=999

gets a clean 422 instead of defeating the whole point. And I named the behavior change out loud — the list now returns max 50 by default where it used to return everything — so future-me isn't surprised when the frontend eventually needs a "load more."

The one mental model to never forget: git history is a security camera that never stops recording, and deleting a file does not delete its past. If a secret appeared in any commit — even one I later "undid" — it's still recoverable and will go public the moment I push.

So a secrets audit is really three questions, and people who get burned only ask the first:

Question Command What it proves
Tracked now?
`git ls-files \ grep -iE "env\
File ever committed? {% raw %}git log --all --oneline -- backend/.env
Past tense, by filename
Value ever committed, any file?
git log --all --oneline -S "actual-value"
Past tense, by content

All three came back clean. But question 1 taught a bonus lesson: it matched backend/alembic/env.py

— Alembic's migration script, which just has the letters "env" in its name. A total false positive. A filter matches strings, not meaning — so you always eyeball what matched instead of trusting the filter.

The through-line from question 3: a secret leaks by content, not filename. Someone hardcodes SECRET_KEY = "..."

into a .py

"just to test," commits it, then later moves it to .env

. The .env

history is spotless — but the value is in that old commit forever. Only the pickaxe (git log -S

) searching for the actual value catches that.

(Honest note I wrote down: a clean pickaxe only proves the current values were never committed. If a secret has ever lived somewhere I'm unsure about, the bulletproof move before a public deploy is to rotate it anyway. Rotation beats archaeology.)

Phase 7b's theme was "everything that isn't your code lying to you." Phase 8's theme was quieter and more humbling: my own typing, and tools doing exactly what I typed instead of what I meant. Almost none of it was in the application code.

My shiny new exception handler ran, the client got its 500 — but the server log was garbage:

Message: 'Unhandled error [$s] on $s %s'
Arguments: ('a06f2a97', 'GET', '/debug/boom')

See the $s

? I'd typed $s

instead of %s

. Python's logging

uses %

-style formatting, so the interpolation failed and it dumped the raw message + args block. I fixed the middle one, re-ran — and it still broke, because I'd missed the one inside the brackets: [$s]

. On a US keyboard %

is Shift+5 and $

is Shift+4, adjacent keys. I hit the wrong one, in the same line, twice.

The lesson isn't "type carefully." It's that the whole point of the handler — a readable server-side log — was silently defeated by one wrong character, and the only reason I caught it was reading the actual log output instead of assuming it worked. Same energy as Phase 7b's silent except

.

I added test_cross_user_isolation

, ran pytest, saw green, and almost celebrated. Then I read the first line:

collected 3 items

Three. I'd written a fourth. It wasn't failing — it didn't exist as far as pytest was concerned. The function had gotten indented so it was nested inside another, which makes it invisible to collection (pytest only finds top-level test_*

functions). No error, no failure, just... absent.

Now collected N items

is the first thing I read, before the pass/fail. A green suite that silently skips your most important test is worse than a red one.

Coming back after a few days, my saved token was stale (JWTs expire; shell variables die with the terminal). Fine — re-authenticate. Except the login kept returning a KeyError: 'access_token'

. I tried passwordd

. Failed. I knew the password was Passwordd

with a capital P — tried that. Still failed.

The fix, as always: stop guessing, strip the pipeline, read the raw response. The real problem was the email — I'd been typing frontend1@test.com

when the account was Frontend1@test.com

. Capital F.

The design lesson underneath: my login returns the same generic 401

for a bad email or a bad password — which is correct (never tell an attacker which field was wrong) — but it means the error can't tell you which one you fat-fingered. That's exactly why "read the raw response" beats "stare at the error message."

One curl command died with:

bash: $'\302\226\302\226curl': command not found

\302\226

is the UTF-8 for an invisible control character. Two of them had ridden along when I pasted from a rich-text source, so bash tried to run a command literally named ‖‖curl

. The command was perfect; the paste smuggled in garbage bytes. Same family as Phase 7b's hidden comma — you can't debug what you can't see, so cat -v

(which prints control characters) and re-typing by hand are the escape hatches.

The through-line across all four: before I'd fixed a single line of application code this phase, my keyboard, my clipboard, my shell, and pytest's collector had each quietly done something other than what I intended. The bug is rarely where you first look — and it's often not even in your code.

Same habit as always — try to break it before reality does:

Attack Defense
POST a negative amount Pydantic gt=0 → 422, dies at the schema, never touches the DB
Present a garbage/expired token
get_current_user → clean 401 with WWW-Authenticate
Request another user's expense id Owner-scoped query → 404 (not 403 — don't confirm it exists)
Trigger an unhandled 500 Global handler → generic body + correlation id, full trace stays server-side
Request limit=999999 to dump everything
Query(le=100) → 422
Read a secret out of "deleted" git history Swept by filename and value across all branches → clean
I did (learning) Production would
Manual curl audit, then automated it Audit runs in CI on every push, blocks merge on a regression
5 focused tests Broader coverage: every endpoint, edge cases, a real coverage target
In-memory StaticPool test DB
Same pattern scales fine — just more fixtures and factories
print /logger to stdout
Structured logging with levels + an alert on sustained 500s
Deferred prod CORS to Phase 9 Env-driven allowed origins, locked to the real frontend URL
Called the secret history "clean" Rotate secrets anyway before a public deploy — rotation beats archaeology
Ignored the httpx /httpx2 deprecation warning
Resolve it before it becomes a hard break on a dependency bump

None of these are wrong for where I am. They're known — written down, not pretended away.

$s

log, the uncollected test, the capital-F login — every one was caught by /health

first means any later red is a real failure, not a setup problem.collected N items

is the first line you read.KeyError

in a wrapper hides the {"detail": ...}

that would've told you the answer.dependency_override

poisons every other test. yield

  • teardown keeps them isolated.200

control prevents a false-pass 404

.alembic/env.py

was innocent — always eyeball what matched.The app is trustworthy now. The audit found no leaks, the unexpected 500 has a net, five tests lock in every guarantee that used to live only in my memory, and the git history is clean.

What's left is the last mile: getting it onto the internet. Phase 9 is deployment — starting with the one item I consciously deferred (production CORS, once I know the real frontend URL), then Render itself, where my own lessons come home to roost: Linux means venv/bin/activate

, not Scripts/

, and paths are case-sensitive (the capital-F login bug, now at the OS level). Secrets become real environment variables in a dashboard, not a .env

file. And I'll confirm debug is off, closing the loop on the exception handler.

The features were the fun part. Turns out making them trustworthy was the part that actually taught me something.

See you in the next one.

silentcarry

── more in #developer-tools 4 stories · sorted by recency
── more on @fastapi 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/phase-8-making-it-tr…] indexed:0 read:16min 2026-08-16 ·