The admin page was working fine, but the API didn't perform an authorization check.
A Penetration Test That Exposed How GraphQL Could Compromise Tenant Isolation in SaaS

The admin page was working fine, but the API didn't perform authorization checks
— A penetration test where SaaS tenant isolation collapsed due to a single GraphQL issue
When conducting web penetration testing, you’ll surprisingly come across services like this quite often.
The front end is quite well-built.
The admin menu is well-hidden based on permission levels, and
and access is blocked even if you enter the URL directly.
/admin
403 Forbidden
Session handling is normal.
The JWT also appears to be fine.
CORS is working fine.
No SQL injection vulnerabilities are found.
The scanner doesn’t find anything out of the ordinary either.
For the first few hours, I find myself thinking:
"It’s actually better built than I expected?"
But there’s one thing I’m immediately skeptical of with a service like this.
The UI.
Just because something isn’t visible in the UI doesn’t mean you don’t have permission for it.
Let’s assume the subject of this test is a typical B2B SaaS service.
A single service has multiple corporate customers.
SaaS Platform
├── Tenant A
│ ├── Admin
│ └── Users
│
├── Tenant B
│ ├── Admin
│ └── Users
│
└── Tenant C
├── Admin
└── Users
The key security requirement is simple.
Users in Tenant A must never be able to view data from Tenant B.
This is commonly referred to as “tenant isolation.”
For Enterprise SaaS, this is one of the most critical security boundaries.
Start by creating two test accounts
Testing access control with only one account is not a good approach.
Prepare at least two accounts.
Account A
Tenant: ACME
Role: User
Account B
Tenant: Globex
Role: User
If possible, create multiple roles as well.
ACME_ADMIN
ACME_USER
GLOBEX_ADMIN
GLOBEX_USER
Setting this up in advance makes testing much easier.
This is because the essence of Access Control testing ultimately
Same Request
Different Identity
Different Result
.
I was looking at the login flow in Burp when I noticed GraphQL
I was browsing the web as usual while checking the Burp HTTP History.
Then, I caught a request like this.
POST /graphql HTTP/2
Host: api.example.com
Authorization: Bearer eyJ...
Content-Type: application/json
Looking at the body, it’s GraphQL.
{
"query": "query GetProfile { me { id name email organization { id name } } }"
}
So far, so good.
PortSwigger also explains this as the basic approach for testing GraphQL APIs: using the web interface directly while checking the actual queries and mutations in the proxy history.
After looking at a few more requests, an interesting mutation appears.
mutation UpdateProject($id: ID!, $name: String!) {
updateProject(id: $id, name: $name) {
id
name
}
}
The `variables` look like this.
{
"id": "proj_8f92ad",
"name": "Security Review"
}
This immediately raises a question.
Does the server verify that `
id` belongs to my project?
Access Control does not trust parameters
From a developer’s perspective, this request seems natural.
Authenticated User
|
v
Project ID
|
v
Update Project
However, from a security perspective, there’s a crucial step missing.
Authenticated User
|
v
Project ID
|
v
Ownership Check
|
v
Authorization Check
|
v
Update Project
So, I create a project in Account A.
Account A
Project ID: proj_A111
I also create one in Account B.
Account B
Project ID: proj_B222
Now, all we need to do is check how the server responds when we reference Account B’s project ID using Account A’s token.
The expected result should, of course, be as follows.
403 Forbidden
Alternatively, to hide its very existence,
404 Not Found
it’s entirely possible to do so.
The important thing is that the operation must not be executed.
However, the response was 200
and the test results came back like this.
{
"data": {
"updateProject": {
"id": "proj_B222",
"name": "Security Review"
}
}
}
At first glance, it seems pretty trivial.
Isn’t this just an IDOR?
That’s right.
In REST, this is commonly referred to as IDOR, and in API security, the term BOLA (Broken Object Level Authorization) is also frequently used.
In GraphQL as well, this kind of access-control issue can occur when objects are accessed directly through arguments such as object IDs without proper authorization checks.
However, at the senior level, this is immediately classified as
"Critical!"
in a report.
This is where the real testing begins.
First, verify just one data record
One of the most important principles in live-service penetration testing.
Do only what is necessary to prove the vulnerability.
Just because you can retrieve 10,000 records of another client’s data doesn’t mean you actually need to retrieve all 10,000.
1 Object
|
v
Proof Established
|
v
Stop
Use a test tenant whenever possible.
So, reproduce the issue using only two test accounts.
Tenant A
|
| Unauthorized Request
v
Tenant B Test Object
And leave evidence behind.
Request
Response
Timestamp
Account
Role
Object owner
Expected behavior
Actual behavior
This should be enough.
But it wasn't just a query.
It also checks for other mutations.
mutation DeleteProject($id: ID!) {
deleteProject(id: $id)
}
If there’s the same authorization issue here, that changes the story.
Read Other Tenant Data
|
v
Modify Other Tenant Data
|
v
Delete Other Tenant Data
It escalates from simple information exposure to cross-tenant write access.
However, we must not actually delete production data.
So, we create a single test object in Tenant B.
Name: PENTEST_DELETE_ME
We verify using only that single object.
This is more important in penetration testing than you might think.
Exploiting a large number of vulnerabilities does not equate to skill.
True skill lies in proving a vulnerability as accurately as possible with minimal impact.
At this point, we take another look at the entire API.
If authorization is missing from one resolver, it means other resolvers are likely to follow the same pattern.
So, look for similar operations.
project(id)
document(id)
invoice(id)
member(id)
workspace(id)
apiKey(id)
integration(id)
I also look at mutations.
updateProject
deleteProject
inviteMember
changeRole
regenerateKey
disconnectIntegration
But we don’t just blindly execute them all.
First, create an authorization matrix.
Own Same Tenant Other Tenant
Read Yes ? ?
Update Yes ? ?
Delete Yes ? ?
Invite Yes ? ?
Change Role ? ? ?
A table like this is quite useful in senior penetration testing.
This is because it allows you to examine the permission model itself, rather than just finding a single vulnerability.
The real danger wasn’t the IDOR itself
Then, I looked at the `inviteMember` mutation.
mutation InviteMember(
$organizationId: ID!,
$email: String!,
$role: Role!
) {
inviteMember(
organizationId: $organizationId,
email: $email,
role: $role
) {
id
}
}
There are three important parameters here.
organizationId
email
role
And this thought crossed my mind:
organizationIdBut doesn’t the user submit this directly?
Account A enters Tenant B’s organizationId.
Only in the test environment.
The response is successful.
Then, they check the member list from Tenant B’s test administrator account.
The account invited by Account A is visible.
At this point, the nature of the vulnerability changes completely.
BOLA
|
v
Cross-Tenant Invitation
|
v
Unauthorized Membership
Next, check the Role value.
In the front end, regular users were not allowed to select a role when sending invitations.
In the UI,
MEMBER
.
However, the `role` is present in the request.
Therefore, we only verify the authorization behavior in the permitted test environment.
MEMBER
ADMIN
OWNER
If the server simply trusts the client’s input here, a second problem arises.
Broken Object Authorization
+
Broken Function Authorization
|
v
Cross-Tenant Privilege Escalation
If we simply write the report title as
IDOR in GraphQL API
, the impact won’t be properly conveyed.
The actual problem
Cross-Tenant Account Takeover
via Broken Authorization
.
This is what we call a “chain.”
One of the most interesting aspects of practical white-hat hacking is seeing how vulnerabilities are interconnected, rather than just looking at the CVSS score of a single vulnerability.
For example, if you look at them individually,
Project IDOR
Member Enumeration
Invite Authorization Issue
Role Validation Issue
it’s about this much.
But when combined,
Cross-Tenant Object Access
|
v
Discover Tenant Identifier
|
v
Invite External Account
|
v
Assign Privileged Role
|
v
Tenant Compromise
it becomes this.
This is a pretty important way of thinking in actual penetration testing.
Scanners usually look at each one separately.
People look at the relationships.
That’s why I end up spending more time with Burp Repeater than with a scanner.
Automation is, of course, necessary.
However, there are still many aspects of access control testing that require human thought.
Think of it as constantly asking yourself these questions.
Who am I?
What object am I accessing?
Who owns this object?
Which tenant owns it?
What role should be required?
Where is authorization enforced?
Then, I take a single request and change only the identity.
Request A + Token A
Request A + Token B
I just change the object.
Token A + Object A
Token A + Object B
I change the role.
USER
MANAGER
ADMIN
As you repeat this process, the application’s actual permission structure begins to emerge.
It’s not over even if introspection is turned off
When it comes to GraphQL, there’s a common first step many development teams take.
Disable Introspection
This is a good measure.
It helps reduce schema exposure in production.
However, disabling introspection and authorization are separate issues.
PortSwigger also treats introspection exposure and access control issues as separate attack surfaces in GraphQL security.
In fact, just by looking at front-end JavaScript and the HTTP history, it’s possible to figure out a significant number of queries and mutations.
Browser
|
v
Frontend Application
|
v
GraphQL Requests
|
v
Burp HTTP History
Therefore,
Introspection Disabled
this
GraphQL Secure
does not mean
The most common reaction when discussing this with development teams
When I share findings like this, I often hear this response:
“But the project ID is a UUID, so no one else can know it, right?”
This is where the perspectives of pentesters and developers diverge.
A UUID is not an authorization mechanism.
Hard to Guess
!=
Authorized
Even if the ID is random, it can still be exposed through the following paths.
Logs
URLs
Emails
Exports
Browser History
API Responses
Third-party Integrations
Referrer Data
Other Vulnerabilities
And security is
means it’s difficult for an attacker to figure out the ID.
—but rather
"Even if an attacker knows the ID, they cannot gain access."
That is what it should be.
A quick fix—such as adding an `if` statement to every resolver—isn’t enough.
It’s easy for code like this to slip in as a quick fix.
if (project.ownerId !== user.id) {
throw new ForbiddenError();
}
It does help plug the vulnerability immediately.
However, in SaaS, the authorization model is usually much more complex.
User
|
+-- Role
|
+-- Organization
|
+-- Workspace
|
+-- Project
|
+-- Resource
If you implement authorization logic separately in 40 resolvers, one will inevitably be missed at some point.
In fact, we can assume that this issue arose in exactly that way.
Therefore, a centralized authorization layer is a better solution for fundamental remediation.
Conceptually, the structure looks like this.
Request
|
v
Authentication
|
v
Authorization Policy
|
+-- Tenant
+-- Role
+-- Ownership
+-- Action
|
v
Resolver
The key point is
Default Allow
not
Default Deny
.
And a retest is mandatory
The security team reports the findings.
The development team fixes it.
Then this response comes back:
"The fix is complete."
You must not close the ticket right away.
Run the same authorization matrix again.
Own Same Tenant Other Tenant
Read PASS PASS PASS
Update PASS PASS PASS
Delete PASS PASS PASS
Invite PASS PASS PASS
Change Role PASS PASS PASS
And it’s not just about checking the existing endpoints.
Sometimes, developers create new endpoints during the fix process.
Old Resolver
|
v
Patched
New Resolver
|
v
Authorization Missing
So, if possible, review the remediation diff as well.
Ultimately, what a senior pentester looks at isn’t the name of the vulnerability.
In the early stages, you’ll memorize a lot of this.
XSS
SQLi
SSRF
XXE
IDOR
CSRF
SSTI
RCE
Of course, you need to know them.
But the longer you work in the field, the more your questions change.
Where is the trust boundary?
Where is authorization enforced?
What does the server trust?
Can I cross tenant boundaries?
Can two low-severity issues become critical?
What is the minimum safe PoC?
What is the actual business impact?
This case didn’t seem like much at first, either.
Just a single GraphQL request.
updateProject(id)
But as I worked through it step by step,
Object Authorization
|
v
Tenant Boundary
|
v
Invitation
|
v
Role Assignment
|
v
Privilege Escalation
it led to this.
Moments like this are the most fun in the world of ethical hacking.
It’s not because I discovered a major 0-day vulnerability.
It’s because, among the legitimate features that developers had created separately, I discovered a path that no one had ever thought of.
From the scanner’s perspective, it’s just a few API requests.
From a developer’s perspective, they’re just a few legitimate features.
But from an attacker’s perspective, it’s a single attack path.
And what a penetration tester does, ultimately, is view the service from that third perspective.
“This feature itself is legitimate, but would it still be legitimate if I used them in this order?”