What Strangers Shouldn't See: Securing the Public Provider API
One of the harder design problems in RhizoBook is that the provider list needs to be public. Patients browse providers before they create an account — that’s the whole point of the app. But “public” doesn’t mean “return everything in the database.”
The include trap
When I first wrote the provider query, it looked something like this:
return this.prisma.user.findMany({
where: { role: { name: 'provider' } },
include: {
providerProfile: {
include: {
availabilitySlots: true,
},
},
},
});
This works. It also returns email, licenseNumber, userId, isActive, createdAt, updatedAt, and every other column on those models — silently, without any error or warning. Prisma’s include means “join this relation and return all its columns.” If you don’t think about what that means for a public endpoint, you’ve just built a data exposure bug.
In a healthcare context, licenseNumber and email are exactly the fields you don’t want on an unauthenticated endpoint. It took writing the ADR to realize this query needed to change.
ADR 001: Explicit select at every nesting level
ADR 001 documents the decision to replace every include with an explicit select shape on any public-facing query. The public provider response is now defined by the fields listed in the select — nothing more:
return this.prisma.user.findMany({
where: { role: { name: 'provider' } },
select: {
id: true,
name: true,
providerProfile: {
select: {
specialty: true,
bio: true,
appointmentDuration: true,
availabilitySlots: {
select: {
id: true,
dayOfWeek: true,
startTime: true,
endTime: true,
},
},
},
},
},
});
The public response is now limited to: User.{id, name}, ProviderProfile.{specialty, bio, appointmentDuration}, AvailabilitySlot.{id, dayOfWeek, startTime, endTime}. Email, licenseNumber, userId, isActive, and all timestamps are excluded — not by filtering the output, but by never fetching them.
See docs/adr/001-public-provider-api-field-selection.md.
The two-axios-instance pattern
This select discipline on the backend only matters if the frontend consistently uses the right client for the right calls.
lib/api.ts is an authenticated axios instance. It sets baseURL: '/v1' and attaches a JWT Bearer token via a request interceptor (reading from the NextAuth session). All patient and provider actions that require login go through this instance.
For provider browsing, the frontend uses plain axios.get('/v1/providers') — no imported instance, no interceptor, no session lookup. The distinction is visible at the call site. If you see the authenticated client used for a public endpoint, that’s a code smell.
Specialty filter and the location decision
The provider list supports one filter: specialty. It uses Prisma’s contains with mode: 'insensitive' for a case-insensitive partial match. Searching “cardio” returns cardiologists.
Location search is not implemented. The marketing page’s search bar was originally designed with both a specialty field and a location field. I removed the location input.
The reason: ProviderProfile has no location column. Shipping a location search field that silently returns all providers regardless of what you type is worse than not having the field at all. Users would assume it works. The spec for location search is in the roadmap — it requires both a schema migration and UI work — but it doesn’t exist yet, so the UI doesn’t pretend it does.
Key files:
backend/src/providers/providers.service.tsfrontend/lib/api.tsdocs/adr/001-public-provider-api-field-selection.md
// comments via github discussions