{"slug": "phase-8-making-it-trustworthy-hardening-a-fastapi-app-with-an-audit-a-test-net-a", "title": "Phase 8 — Making It Trustworthy: Hardening a FastAPI App with an Audit, a Test Net, and a Logging Bug I Typed Twice", "summary": "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.", "body_md": "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.\"\n\nNo 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.\n\nSpoiler: the app had zero leaks. Almost every bug this phase was in the *terminal*, not the code.\n\n`/health`\n\nendpointPhase 7b ended with the seam holding: a category column, a rules engine, and a Gemini-backed `LLMCategorizer`\n\nliving behind the same `Categorizer`\n\ncontract — validated, cached, and with a fallback that was finally loud about failing.\n\nI 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*.\n\nBut 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.\n\nThe 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:\n\n| Step | What | Why this order |\n|---|---|---|\n| 1 | A `/health` endpoint |\nPurely additive warm-up; also the thing my host will ping in Phase 9 |\n| 2 | Audit the real API for leaks | Get evidence before writing a single fix — don't guess what leaks |\n| 3 | Global exception handler | The one gap the audit couldn't test: the unexpected 500 |\n| 4–5 | A pytest suite | Turn every manual proof into a permanent, re-runnable one |\n| 6 | Pagination | Put a ceiling on an unbounded list before it becomes a problem |\n| 7 | Secrets / git-history sweep | The one item with real downside if skipped before going public |\n\nThe 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.\n\n`/health`\n\nendpoint\nEvery real route in my app is auth-gated behind `get_current_user`\n\n. 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`\n\n.\n\n``` python\n@app.get(\"/health\")\ndef health_check():\n    return {\"status\": \"ok\"}\n```\n\n| Idea | Why it matters |\n|---|---|\nNo `Depends` , no auth |\nThe absence is the point — a monitor hits it without a token |\n| No DB call | It answers \"is the process up,\" not \"is every subsystem healthy.\" A deeper check is a conscious later step |\nReturns a plain `dict`\n|\nFastAPI serializes it to JSON automatically — no `response_model` needed for something this trivial |\n\nAdditive, 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.\n\nThis 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.\n\nI grabbed a token once, stashed it in a shell variable, and fired four deliberately-bad requests. Here's the evidence table that came out:\n\n| # | Probe | Expected | Actual | Verdict |\n|---|---|---|---|---|\n| 1 | POST expense, negative amount | 422 | 422 | Input validation holds |\n| 2 | GET `/expenses` , garbage token |\n401 | 401 | Auth boundary holds |\n| 3 | GET a non-existent expense id | 404 | 404 | Not-found path is clean |\n| 4 | GET another user's expense id | 404 | 404 | Cross-user isolation holds |\n\nTwo things I want to call out, because they're *design* wins, not luck:\n\n**Probe #1 dies at the schema, not the database.** My `ExpenseCreate`\n\ndeclares `amount`\n\nwith `gt=0`\n\n, 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.\n\n**Probe #4 returns 404, not 403 — on purpose.** My query filters by *both* `id`\n\nand `user_id == current_user.id`\n\n, so a row I don't own comes back as `None`\n\n→ 404. A `403 Forbidden`\n\nwould 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`\n\nsays \"nothing here for you\" and reveals nothing.\n\nThe 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.\n\nThe 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.\n\nFastAPI already handles two error families: my intentional `HTTPException`\n\ns (401/404/409) and validation errors (422). What it doesn't handle is the *unexpected* — a bug, a `None`\n\nwhere 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.\n\n``` python\n@app.exception_handler(Exception)\nasync def unhandled_exception_handler(request: Request, exc: Exception):\n    error_id = uuid.uuid4().hex[:8]\n    logger.exception(\n        \"Unhandled error [%s] on %s %s\", error_id, request.method, request.url.path\n    )\n    return JSONResponse(\n        status_code=500,\n        content={\"detail\": \"Internal server error\", \"error_id\": error_id},\n    )\n```\n\n| Concept | What it does |\n|---|---|\n`@app.exception_handler(Exception)` |\nCatches only what a more specific handler didn't — my `HTTPException` s and 422s keep their exact status codes |\n`logger.exception(...)` |\nLogs at ERROR level with the full traceback — the server-side record |\n`uuid4().hex[:8]` |\nA short correlation id, printed in both the log and the response |\nGeneric `{\"detail\", \"error_id\"}` body |\nThe client sees a reference code to quote to support — never internals |\n\nThe critical nuance is that first row. A handler on the broad `Exception`\n\ntype does *not* swallow my deliberate `HTTPException`\n\ns — 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.\n\nAnd I proved it — not by faith, by a throwaway `/debug/boom`\n\nroute 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.)\n\nEverything 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.\n\nFastAPI's `TestClient`\n\ncalls the app **in-process** — no uvicorn, no network, fast and isolated. I proved the harness with one trivial `/health`\n\ntest first (prove the tooling before you trust the result), then added the real ones.\n\nThe interesting piece is testing *authenticated* paths without a live login, using `dependency_overrides`\n\n— and the trap that comes with it:\n\n``` python\n@pytest.fixture\ndef as_authenticated_user():\n    def _fake_user():\n        return User(id=1, name=\"Test User\", email=\"test@example.com\", hashed_password=\"x\")\n    app.dependency_overrides[get_current_user] = _fake_user\n    yield\n    app.dependency_overrides.clear()\n```\n\n| Concept | What it does |\n|---|---|\n`dependency_overrides` |\nSwaps a real dependency for a fake — auth is satisfied, the route runs |\nA fixture, not a global |\nThe trap: a global override poisons every test, silently breaking the 401 test |\n`yield` split |\nEverything before is setup, everything after is teardown — the `.clear()` is what keeps tests isolated |\n\nThe proof that the teardown works: after adding the fixture, my `test_expenses_requires_auth`\n\ntest *still* returned 401. If the override had leaked, it would've gotten the fake user and failed. Isolation intact.\n\nThe 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`\n\n. That means a proper test-database fixture — and the trickiest concept of the whole phase.\n\n``` python\n@pytest.fixture\ndef test_db():\n    engine = create_engine(\n        \"sqlite://\",\n        connect_args={\"check_same_thread\": False},\n        poolclass=StaticPool,\n    )\n    Base.metadata.create_all(bind=engine)\n    TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)\n\n    def _override_get_db():\n        db = TestingSessionLocal()\n        try:\n            yield db\n        finally:\n            db.close()\n\n    app.dependency_overrides[get_db] = _override_get_db\n    seed = TestingSessionLocal()\n    try:\n        yield seed\n    finally:\n        seed.close()\n        app.dependency_overrides.clear()\n        Base.metadata.drop_all(bind=engine)\n```\n\nThe `StaticPool`\n\nline is the one to internalize. `TestClient`\n\nruns 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`\n\nforces 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.\n\nThe test itself seeds two users and one expense owned by user2, then makes the *same* request as each identity:\n\n``` python\ndef test_cross_user_isolation(test_db):\n    # ... seed user1, user2, and an expense owned by user2 ...\n\n    app.dependency_overrides[get_current_user] = lambda: User(id=u2_id)\n    owner = client.get(f\"/expenses/{expense_id}\")\n    assert owner.status_code == 200      # the control\n\n    app.dependency_overrides[get_current_user] = lambda: User(id=u1_id)\n    other = client.get(f\"/expenses/{expense_id}\")\n    assert other.status_code == 404      # the guarantee\n```\n\nThe `200`\n\nassertion 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`\n\ncan *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.\n\n(This test almost didn't run at all — see the war story.)\n\nMy `list_expenses`\n\nwas 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:\n\n``` python\n@app.get(\"/expenses\", response_model=List[ExpenseRead])\ndef list_expenses(\n    db: Session = Depends(get_db),\n    current_user: User = Depends(get_current_user),\n    limit: int = Query(default=50, ge=1, le=100),\n    offset: int = Query(default=0, ge=0),\n):\n    expenses = db.scalars(\n        select(Expense)\n        .where(Expense.user_id == current_user.id)\n        .order_by(Expense.spent_on.desc(), Expense.id.desc())\n        .limit(limit)\n        .offset(offset)\n    ).all()\n    return expenses\n```\n\nThe 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\n\n`order_by`\n\n.The actual hardening is `le=100`\n\n: a caller asking for `limit=999`\n\ngets 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.\"\n\nThe 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.\n\nSo a secrets audit is really three questions, and people who get burned only ask the first:\n\n| Question | Command | What it proves |\n|---|---|---|\nTracked now? |\n`git ls-files \\ | grep -iE \"env\\ |\n| File ever committed? | {% raw %}`git log --all --oneline -- backend/.env`\n|\nPast tense, by filename |\nValue ever committed, any file? |\n`git log --all --oneline -S \"actual-value\"` |\nPast tense, by content |\n\nAll three came back clean. But question 1 taught a bonus lesson: it matched `backend/alembic/env.py`\n\n— 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.\n\nThe through-line from question 3: a secret leaks by *content*, not filename. Someone hardcodes `SECRET_KEY = \"...\"`\n\ninto a `.py`\n\n\"just to test,\" commits it, then later moves it to `.env`\n\n. The `.env`\n\nhistory is spotless — but the value is in that old commit forever. Only the pickaxe (`git log -S`\n\n) searching for the actual value catches that.\n\n(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.)\n\nPhase 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.\n\nMy shiny new exception handler ran, the client got its 500 — but the server log was garbage:\n\n```\nMessage: 'Unhandled error [$s] on $s %s'\nArguments: ('a06f2a97', 'GET', '/debug/boom')\n```\n\nSee the `$s`\n\n? I'd typed `$s`\n\ninstead of `%s`\n\n. Python's `logging`\n\nuses `%`\n\n-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]`\n\n. On a US keyboard `%`\n\nis Shift+5 and `$`\n\nis Shift+4, adjacent keys. I hit the wrong one, in the same line, twice.\n\nThe 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`\n\n.\n\nI added `test_cross_user_isolation`\n\n, ran pytest, saw green, and almost celebrated. Then I read the first line:\n\n```\ncollected 3 items\n```\n\nThree. 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_*`\n\nfunctions). No error, no failure, just... absent.\n\nNow `collected N items`\n\nis 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.\n\nComing 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'`\n\n. I tried `passwordd`\n\n. Failed. I *knew* the password was `Passwordd`\n\nwith a capital P — tried that. Still failed.\n\nThe 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`\n\nwhen the account was `Frontend1@test.com`\n\n. Capital F.\n\nThe design lesson underneath: my login returns the same generic `401`\n\nfor 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.\"\n\nOne curl command died with:\n\n```\nbash: $'\\302\\226\\302\\226curl': command not found\n```\n\n`\\302\\226`\n\nis 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`\n\n. 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`\n\n(which prints control characters) and re-typing by hand are the escape hatches.\n\nThe 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.\n\nSame habit as always — try to break it before reality does:\n\n| Attack | Defense |\n|---|---|\n| POST a negative amount | Pydantic `gt=0` → 422, dies at the schema, never touches the DB |\n| Present a garbage/expired token |\n`get_current_user` → clean 401 with `WWW-Authenticate`\n|\n| Request another user's expense id | Owner-scoped query → 404 (not 403 — don't confirm it exists) |\n| Trigger an unhandled 500 | Global handler → generic body + correlation id, full trace stays server-side |\nRequest `limit=999999` to dump everything |\n`Query(le=100)` → 422 |\n| Read a secret out of \"deleted\" git history | Swept by filename and value across all branches → clean |\n\n| I did (learning) | Production would |\n|---|---|\n| Manual curl audit, then automated it | Audit runs in CI on every push, blocks merge on a regression |\n| 5 focused tests | Broader coverage: every endpoint, edge cases, a real coverage target |\nIn-memory `StaticPool` test DB |\nSame pattern scales fine — just more fixtures and factories |\n`print` /`logger` to stdout |\nStructured logging with levels + an alert on sustained 500s |\n| Deferred prod CORS to Phase 9 | Env-driven allowed origins, locked to the real frontend URL |\n| Called the secret history \"clean\" | Rotate secrets anyway before a public deploy — rotation beats archaeology |\nIgnored the `httpx` /`httpx2` deprecation warning |\nResolve it before it becomes a hard break on a dependency bump |\n\nNone of these are wrong for where I am. They're *known* — written down, not pretended away.\n\n`$s`\n\nlog, the uncollected test, the capital-F login — every one was caught by `/health`\n\nfirst means any later red is a real failure, not a setup problem.`collected N items`\n\nis the first line you read.`KeyError`\n\nin a wrapper hides the `{\"detail\": ...}`\n\nthat would've told you the answer.`dependency_override`\n\npoisons every other test. `yield`\n\n+ teardown keeps them isolated.`200`\n\ncontrol prevents a false-pass `404`\n\n.`alembic/env.py`\n\nwas 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.\n\nWhat'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`\n\n, not `Scripts/`\n\n, 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`\n\nfile. And I'll confirm debug is off, closing the loop on the exception handler.\n\nThe features were the fun part. Turns out making them *trustworthy* was the part that actually taught me something.\n\nSee you in the next one.\n\nsilentcarry", "url": "https://wpnews.pro/news/phase-8-making-it-trustworthy-hardening-a-fastapi-app-with-an-audit-a-test-net-a", "canonical_source": "https://dev.to/silentcarry/phase-8-making-it-trustworthy-hardening-a-fastapi-app-with-an-audit-a-test-net-and-a-logging-11c8", "published_at": "2026-08-16 13:58:28+00:00", "updated_at": "2026-08-16 14:12:47.292096+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["FastAPI", "Gemini", "pytest"], "alternates": {"html": "https://wpnews.pro/news/phase-8-making-it-trustworthy-hardening-a-fastapi-app-with-an-audit-a-test-net-a", "markdown": "https://wpnews.pro/news/phase-8-making-it-trustworthy-hardening-a-fastapi-app-with-an-audit-a-test-net-a.md", "text": "https://wpnews.pro/news/phase-8-making-it-trustworthy-hardening-a-fastapi-app-with-an-audit-a-test-net-a.txt", "jsonld": "https://wpnews.pro/news/phase-8-making-it-trustworthy-hardening-a-fastapi-app-with-an-audit-a-test-net-a.jsonld"}}