Previously, in Part 10, we fixed two real bugs by making the AI reason from the spec. ClinicLive now does the right thing — but "works" and "safe to put on the internet" are different claims. Before Part 12 deploys anything, we hand the AI its least friendly hat and ask it to attack its own app.

The prompt: findings first, fixes later

Here's the prompt, verbatim from the commit in the companion repo (tag part-11):

"Review this app the way a hostile security reviewer would. List
everything, ranked by severity, with the file that proves it. Do not fix
anything yet — findings first."

Every clause is load-bearing. Hostile flips the AI out of its default helpful-builder posture — the same model that cheerfully shipped these files will happily prosecute them, but only if you ask for the prosecution. Ranked by severity forces an ordering you can act on. The file that proves it keeps the findings honest — no vague "consider improving auth" filler, only claims you can open in an editor. And do not fix anything yet is the clause we'd defend in court: told to "review and fix", an AI will quietly patch half of what it finds, report the rest, and hand you one mixed diff you'll rubber-stamp. Findings-first keeps you the judge. Each fix then becomes its own reviewed, revertable commit — and nothing gets "fixed" without you ever learning it was broken.

Model pick: Opus, high effort, for the review itself — adversarial reading is a thinking task, and severity judgment is exactly where the cheap model hedges. The fixes that followed were mostly deletions and one-liners any model could have typed.

The review came back ranked. Here are the four findings that made the cut, each with its proof:

The hostile review, ranked
#SeverityFindingThe file that proves it
1HighOpen self-registration — anyone can create a staff accountComponents/Account/Pages/Register.razor
2MediumAny logged-in user can run the queue; spec says reception doesComponents/Pages/Staff/Queue.razor
3MediumThe status column accepts any text a raw UPDATE sends itData/ApplicationDbContext.cs
4LowDay-range queries can't use the partial unique indexEXPLAIN output on the staff pages' query

Finding #1: the front door we never locked

The top finding was embarrassing, and it had been true since Part 5: the ASP.NET Identity template ships with open self-registration. Anyone who could reach the app could visit the register page, create an account, and walk straight into the staff area — today's appointments, patient names and phone numbers, the queue controls, the staff chat. No exploit required; the feature was working exactly as templated.

Why did nobody notice for six parts? Because our design made it invisible. Staff accounts are seeded, so every login in every demo used an existing account — nobody ever had a reason to visit the register page, so its existence never crossed anyone's mind. The vulnerability lived precisely in the gap between "the flows we use" and "the flows that exist."

The fix is the part worth internalizing: delete the surface, not the links. Hiding the "Register" link is theater — the page and its endpoints still answer to anyone who types the URL. This commit removes the whole registration and external-login surface: Register.razor, RegisterConfirmation.razor, ExternalLogin.razor, the external-logins management page, the login picker component, and their endpoints in the Identity endpoint extensions — around 650 lines gone. A surface that no longer exists can't be re-enabled by a stray link or a guessed URL. New staff accounts are an admin task on the server, which for a small clinic is exactly right.

What the AI got wrong: the AI shipped this door in Part 5 and then built six parts on top without once flagging it — and that's the pattern to remember, not a one-off. A builder prompt inherits template defaults without questioning them; the model optimizes for "make it work," and open registration does work. It took the reviewer prompt — a different job description, same model — to see it. If you never explicitly ask for the hostile pass, nobody on your two-person team of you and the AI is doing security.

Finding #2: logged in is not the same as allowed

The staff queue page was protected by a bare login check — any authenticated user could call the next patient. Our spec is explicit that reception runs the queue; practitioners see their appointments, they don't work the desk. With roles already seeded since Part 5, the fix is one attribute:

@page "/staff/queue"

@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = ClinicLive.Data.DbSeeder.ReceptionRole)]

Small change, important habit: authentication answers "who are you?", authorization answers "and what are you allowed to do here?" A template gives you the first for free, which makes it dangerously easy to ship an app that never asks the second question. Walk your pages and check each one against the spec's idea of who does what.

Finding #3: teach the database to say no

Back in Part 5 we chose to store the appointment status enum as text — readable in psql, safe to reorder the C# enum. The review pointed out the cost we hadn't paid yet: every guarantee about that column lived in C#. One raw UPDATE in a psql session, one future admin script, one other client — and status holds 'Bananas', a value no switch statement in the app has ever heard of. C# checks only protect you from code that goes through C#.

The answer is the database's own vocabulary — a CHECK constraint. Here's the real configuration from ApplicationDbContext:

// Status is stored as text; the database should refuse values the enum
// doesn't have (a raw UPDATE can bypass every C# check).
e.ToTable(t => t.HasCheckConstraint("ck_appointments_status",
    "status IN ('Booked', 'CheckedIn', 'InProgress', 'Done', 'Cancelled', 'NoShow')"));

This is the same philosophy that gave us the partial unique index in Part 6: the database is the last line of defense, and constraints are the only checks that apply to every client — including the ones that don't exist yet. The application validates for friendly error messages; the database enforces so that invalid states are unrepresentable.

Finding #4: the index EXPLAIN demanded

The subtlest finding came from running EXPLAIN on the staff pages' bread-and- butter query: "every appointment in this day range, whatever its status." We have an index on starts_at, so that's covered — except we don't, not really. Our unique index from Part 6 is partial: its filter excludes cancelled and no-show rows, which is exactly what makes re-booking a freed slot possible. But a partial index can only serve queries whose conditions imply its filter, and the staff pages ask for all statuses. The planner, quite correctly, refused it — EXPLAIN showed a sequential scan over the appointments table.

The fix is a second, ordinary index on the same column with no filter, sitting right under its specialized sibling:

// Two ACTIVE appointments can never share a slot; a cancelled one frees it.
e.HasIndex(a => a.StartsAt)
    .IsUnique()
    .HasFilter("status NOT IN ('Cancelled', 'NoShow')");

// Part 11 hardening: the partial index above only covers ACTIVE rows, so
// the all-statuses day-range queries (staff pages) get their own index —
// EXPLAIN showed a seq scan without it.
e.HasIndex(a => a.StartsAt).HasDatabaseName("ix_appointments_starts_at_all");

The general lesson: a partial index is a contract about which rows it knows about, and queries outside that contract get nothing from it. If you use partial indexes for integrity — as we do — check that your read paths have an index that covers them too. New to how indexes make that difference? Start with our indexes explainer; for partial indexes, plans and the planner's reasoning, the PostgreSQL series digs deeper in Indexes, MVCC and performance.

Confession, discovered after this part shipped: that second HasIndex call above didn't actually work. EF Core identifies an index by its property list, so two HasIndex(a => a.StartsAt) calls silently merge into one — our "new index" just renamed the old one, and the migration looked plausible enough that nobody noticed. A reviewer caught it while fact-checking this very series. The cure is distinct index names on both calls, pinned with HasDatabaseName — the fix commit ("Fix Part 11's index that never was") on the repo tells the whole story. Even the hardening pass ships bugs. Review the reviewer.

See it yourself: run EXPLAIN (ANALYZE, BUFFERS) on your own day-range query before and after an index like this, and paste both plans into our free EXPLAIN explainer — it narrates the plan in plain English and will point straight at a sequential scan that shouldn't be there.

Make the pass a ritual

Four findings, four commits, and the whole exercise took an evening. That's the real headline: a decent security review used to be something small teams did rarely, because it cost a consultant or a week. With AI it costs a prompt and your judgment on the findings — which means the discipline shifts from affording review passes to scheduling them. Ours now runs before every deploy, same hostile prompt, and findings-first every time. The review is cheap; the habit is the feature.

The meter: ≈ $5.60 total so far. The full hostile review plus all four fixes came to about $0.70 — the cheapest security audit this clinic will ever get, and the ranked findings list was worth more than the fixes.

Checkpoint: git checkout part-11 in the repo: the register page is simply gone, /staff/queue turns away anyone without the Reception role, psql refuses an UPDATE to a nonsense status, and dotnet test still shows all 9 green — hardening changed what the app refuses, not what it does.

The app is tested, debugged and hardened — it has officially run out of excuses not to meet the public. Next time we put it on a real Linux VPS: systemd, nginx, GitHub Actions, and the three proxy headers that separate "works locally" from "works in production" — Part 12: Ship it — a VPS, nginx, systemd, and the headers SignalR needs.