October 2, 2026
I Found a Hidden GraphQL Search That Exposed Employee Emails And Earned $1200 for It
How a hidden GraphQL query, a client-side authorization check, and an unescaped SQL LIKE wildcard turned an ordinary employee account into…

By Manikesh
11 min read
How a hidden GraphQL query, a client-side authorization check, and an unescaped SQL LIKE wildcard turned an ordinary employee account into an organization-wide information disclosure vulnerability.
Introduction
One habit I've developed while hunting for vulnerabilities is reading the JavaScript bundles of applications I use.
Not just to understand how the frontend works, but to understand what the application is capable of doing behind the scenes.
Sometimes, the most interesting functionality isn't visible in the UI.
That was exactly what happened during a recent bug bounty investigation.
I was testing an employee security-awareness platform through a HackerOne program. I had a completely ordinary employee account, without administrative privileges.
While exploring the application, I noticed that the settings sidebar contained a section called Users, but it wasn't available to my account.
Normally, that would be the end of the story.
But I had already downloaded the application's JavaScript bundle.
And the frontend code revealed something interesting.
There was an entire GraphQL query responsible for retrieving the organization's user directory. The application simply wasn't executing it for my account.
That observation eventually led to a vulnerability that allowed me to retrieve an organization's employee directory and recover email addresses that the application explicitly returned as null through its normal profile fields.
Here's how I found it.
1. The First Discovery: A Hidden GraphQL Query
While analyzing the minified JavaScript bundle, I found the GraphQL operation responsible for loading the administrative Users page.
The frontend contained an authorization-dependent skip condition resembling:
skipAccounts: !canViewUsers.valueskipAccounts: !canViewUsers.valueIn simple terms:
- If the user has permission to view the directory, execute the query.
- Otherwise, skip the query entirely.
The application was relying on frontend logic to prevent unauthorized users from accessing this functionality.
I wanted to understand whether the backend independently enforced the same restriction.
So, using my own authenticated session, I reproduced the underlying GraphQL request.
The result was unexpected.
The server returned:
- 648 organization accounts.
- Employee names.
- Account creation dates.
- Last-active timestamps.
- Internal account identifiers.
Interestingly, the same response also contained:
{
"canViewUsers": {
"value": false
}
}{
"canViewUsers": {
"value": false
}
}Despite explicitly indicating that my account couldn't view users, the backend had returned the directory.
This was my first finding: an authorization enforcement issue involving a GraphQL connection.
However, there was another interesting detail.
The email field was consistently returned as null.
I had access to the directory, but not the information I was most interested in investigating.
2. Trying to Retrieve the Missing Information
I wanted to understand whether email addresses were genuinely inaccessible or whether another part of the application exposed them.
I explored several possible approaches.
Direct GraphQL field queries
I started with individual account profiles.
The application exposed an account lookup through a profile slug, so I queried individual profiles and examined the available fields.
I tested fields such as:
- Roles
- Points
- Real IDs
- Profile information
- Other account-related properties
The email field consistently returned null for other users.
This suggested that the application had some form of field-level privacy enforcement.
GraphQL introspection
Next, I tried standard GraphQL introspection.
Both __schema and __type were unavailable.
Field suggestions were disabled as well, meaning invalid field names didn't provide useful schema information.
This made discovering additional functionality more difficult.
Password reset endpoint
Another possibility was using the password reset functionality to distinguish valid email addresses from invalid ones.
However, the application had CAPTCHA protection and returned identical HTTP 422 responses for both valid-looking and nonexistent addresses.
There was no useful distinction to exploit.
Email confirmation resend endpoint
I also examined the email confirmation resend functionality.
It behaved differently for a limited category of accounts that had initiated registration but never completed it.
Although interesting, this didn't help retrieve existing employee email addresses.
An unexpected observation: Email sorting
While experimenting with the directory query, I noticed that its sorting functionality accepted an EMAIL enum value.
This was interesting because I couldn't actually retrieve the email field.
The application allowed me to sort accounts by a value that it wouldn't allow me to read.
Sorting revealed relative ordering, although it wasn't sufficient to reconstruct individual email addresses.
At this point, I had explored several possibilities without recovering a single address.
Then I started looking at something I had previously overlooked.
3. The Breakthrough: Inspecting GraphQL Arguments
Until this point, most of my investigation had focused on the fields returned by GraphQL.
I was asking:
What information can this query return?
I changed my approach.
Instead, I started investigating:
What functionality does this query accept?
While examining another part of the JavaScript bundle, specifically the team invitation functionality, I found a related GraphQL connection.
Its generated query structure exposed additional arguments:
firstafterbeforelastsearchTermfilters
The searchTerm argument immediately caught my attention.
The directory query supported searching, but the application didn't expose that search functionality through my normal user interface.
I decided to investigate its behavior using an account I controlled.
The important discovery was that the search operation appeared to match information beyond the fields visible in the directory.
I began with a known account and observed how the result count changed when different search terms were supplied.
Certain fragments produced matches even when they weren't present in the account's username or display name.
That was the turning point.
The search functionality was revealing information through its matching behavior, even though the corresponding field was hidden from the response.
4. Understanding the Root Cause: SQL LIKE Wildcards
I wanted to determine what kind of matching mechanism was being used.
A few controlled experiments revealed some interesting behavior.
The search functionality appeared to follow SQL LIKE semantics.
Two characters were particularly important:
- % — matches any sequence of characters.
- _ — matches exactly one character.
For example, a search pattern using % matched the entire directory.
A pattern containing _ also matched accounts where a single character differed.
Meanwhile, regular expression syntax didn't produce equivalent results.
This strongly indicated that the backend was using SQL-style pattern matching rather than regular expressions.
The underlying problem was that user-controlled search input was being interpreted as a pattern rather than treated as literal text.
More importantly, the search appeared to include the email column.
I verified this using controlled test accounts and domain-specific matching behavior.
The application had effectively created an information disclosure oracle.
Even though the email field was hidden, the search functionality could reveal whether a guessed fragment matched the underlying email address.
5. The Interesting Part: Recovering an Email Address Without Ever Reading the Email Field
This is where things became interesting.
I had already established that the application was returning the organization's directory, but direct access to employee email addresses was blocked. Regardless of which profile fields I queried, the email property consistently returned null.
I initially assumed that email addresses were properly protected at the resolver level.
But I hadn't considered whether another functionality could indirectly expose the same information.
Remember the searchTerm argument I discovered earlier?
I started experimenting with it using an account I controlled. Instead of relying on the returned fields, I focused on how the totalCount value changed when different search terms were supplied.
That small change in approach made all the difference.
Discovering the matching behavior
My first experiments involved checking how the search functionality handled special characters.
I observed three particularly interesting behaviors:
- A standalone % matched the entire directory.
- The _ character behaved like a single-character wildcard.
- Regular expression syntax didn't produce equivalent matches.
These observations strongly suggested that the backend was using SQL LIKE-style pattern matching.
The important detail was that wildcard characters were not being escaped before the search operation evaluated them.
This meant the search argument wasn't necessarily being interpreted as literal text.
Identifying the email column
The next challenge was figuring out which database column was actually being searched.
A successful search alone wasn't enough. The application could have been matching usernames, display names, or other profile attributes.
I needed to eliminate those possibilities.
I pinned the query to a specific account ID using the directory connection's ids argument. This allowed me to investigate one account without interference from other directory entries.
I then compared search results against information already visible in that account's profile.
Some search fragments matched even though they weren't present in the account's username or display name.
I also used domain-anchored patterns.
For example, searching for patterns associated with a known email domain allowed me to distinguish email-based matches from username or display-name matches. The @domain portion was particularly useful because it wasn't present in those other fields.
This provided strong evidence that the email column was included in the backend search operation, despite being unavailable through direct GraphQL field selection.
Turning search results into an information oracle
Now I had two important pieces of information:
- The search functionality evaluated the hidden email column.
- The API exposed whether a search matched through
totalCount.
The combination created an information disclosure oracle.
Consider a simplified example:
Suppose the target account has the following email:
employee@example.com
But its GraphQL profile looks like this:
{
"username": "employee",
"email": null
}{
"username": "employee",
"email": null
}A direct query cannot retrieve the email address.
However, the search operation can evaluate patterns against the underlying email value.
By observing whether a carefully constructed search pattern produces a match, it becomes possible to progressively determine the unknown characters.
The key was to use a known domain as an anchor and investigate the unknown local part through successive matching decisions.
In my testing, I first established a known prefix and then used the search response to determine which character extensions continued to match.
Each successful match narrowed down the possibilities.
The process was repeated until the complete address could be confirmed.
I also accounted for ambiguity and verified that the recovered prefix was complete rather than assuming that the first successful match represented the entire address.
The part that made this particularly interesting: The failed approaches
Getting to this point wasn't straightforward.
I had already tried direct profile queries, GraphQL introspection, password-reset behavior, email-confirmation endpoints, and even sorting by the hidden email field.
None of those approaches recovered an existing employee's email address.
The breakthrough came from realizing that I didn't need the application to return the email field.
I only needed to know whether a particular pattern matched it.
That distinction changed the entire investigation.
How quickly could it be done?
After understanding the matching behavior, I developed an automated approach to validate the recovery process against accounts I was authorized to test.
The implementation used controlled, domain-anchored search probes, parallelized requests in small batches, and handled ambiguous matches through backtracking.
I deliberately kept the request rate around eight requests per second to respect the program's rate restrictions.
In my testing, recovering a complete address took approximately 16 requests and around 20 seconds for a known-prefix example.
The automated approach could recover an address in approximately 2–3 minutes, depending on the search space and matching behavior.
The important point is that the application never returned the protected email address directly.
It repeatedly answered a much simpler question:
Does this search pattern match the account?
And those answers were enough to reconstruct information that the profile API explicitly refused to disclose.
This is an example of inference-based information disclosure through an application-level search oracle. It demonstrates why protecting a sensitive field at the response layer is not sufficient when other operations can still evaluate that field and expose the result of those evaluations.
A field returning null does not necessarily mean its underlying data is inaccessible. Sometimes, the application leaks the information through the questions it allows you to ask.
6. Measuring the Impact
Once I understood the behavior, I wanted to determine whether this was limited to an individual account or affected the wider organization.
I examined aggregate search results and the organization directory.
The results revealed:
ObservationResultTotal organization accounts648Accounts associated with one observed domain314Accounts associated with another observed domain162Sampled accounts where email local-part matched username3 out of 8
These observations suggested that a substantial portion of the organization used predictable email domains and naming conventions.
The impact was therefore not limited to discovering whether an arbitrary string existed.
It involved the possibility of recovering employee contact information from an organization-wide directory.
For a security-awareness platform, this is particularly relevant.
An attacker with access to verified employee email addresses could use that information to construct more convincing phishing campaigns.
The same data could also be useful for:
- Employee impersonation.
- Targeted social engineering.
- Account discovery.
- Spear-phishing preparation.
- Mapping organizational identities.
The vulnerability combined two separate security problems:
- Unauthorized access to an organization directory.
- Information disclosure through a search functionality that matched protected email data.
Together, these issues significantly increased the exposure.
6. Why This Wasn't Just a GraphQL Authorization Bug
Initially, this investigation looked like a fairly standard authorization issue.
A user without administrative permissions could execute a query intended for a different role.
But the deeper issue was more interesting.
There were multiple security boundaries that weren't working as expected.
First: Frontend authorization was treated as a security boundary.
The frontend prevented the query from executing, but the backend didn't consistently enforce the same restriction.
Second: Field-level privacy didn't account for indirect information disclosure.
The email field returned null, but another operation could still evaluate the underlying email value.
Third: Search input wasn't handled as literal input.
Wildcard characters influenced the matching behavior, allowing the search operation to act as an information oracle.
This is an important lesson when assessing GraphQL applications.
Authorization isn't just about checking whether someone can retrieve a particular field.
It's also about checking whether they can infer that field through:
- Search operations.
- Sorting.
- Filtering.
- Aggregation.
- Pagination.
- Result counts.
- Error differences.
A field can be hidden in one resolver and still leak through another.
7. Recommended Remediation
There are several changes that would address the underlying issues.
1. Enforce authorization on the backend
Frontend visibility checks should never be considered sufficient authorization.
The backend must verify whether the authenticated user is permitted to access organization-wide account information.
The directory connection should enforce this restriction independently of the frontend.
2. Escape SQL LIKE wildcards
If the intended behavior is literal text searching, special wildcard characters must be escaped before constructing the search pattern.
The backend should distinguish between literal user input and intentional search operators.
Parameterized queries should be used to prevent SQL injection, but parameterization alone does not make wildcard characters literal in a LIKE expression.
Both concerns need to be addressed.
3. Restrict searchable columns
The backend should only search columns that the current user is authorized to access.
If email addresses are private, they should not be included in the searchable column set for ordinary employees.
Hiding a field in GraphQL responses is insufficient if another resolver can still query it indirectly.
4. Review sorting and filtering permissions
Sorting and filtering should follow the same authorization rules as direct field access.
Sensitive columns should not become indirect information sources through ordering, filtering, or aggregate counts.
5. Review organization-level access controls
Every organization-level GraphQL connection should independently enforce authorization.
A user should not be able to access administrative functionality simply because the corresponding operation exists in the frontend bundle.
8. Lessons for Bug Bounty Hunters
This investigation reinforced a few techniques I think are worth incorporating into regular web application testing.
Don't stop at the visible interface.
Frontend JavaScript bundles often contain GraphQL operations, feature flags, hidden queries, and functionality that isn't available to every user.
These can provide valuable leads during an assessment.
Look at arguments, not just returned fields.
A GraphQL operation's arguments can reveal functionality that isn't exposed through the normal UI.
Search, filters, sorting, and pagination deserve particular attention.
Think about indirect information disclosure.
A sensitive field returning null doesn't necessarily mean the information is protected.
Ask whether the application can still search, filter, sort, count, or otherwise evaluate that field.
Investigate result counts.
Developers sometimes underestimate the information exposed by aggregate counts.
A response that only returns totalCount can still reveal whether a particular condition matches an underlying record.
Treat client-side authorization as a lead, not a security control.
Whenever frontend code skips a request based on a permission flag, investigate whether the backend independently validates that permission.
Responsible Disclosure
The vulnerability was reported through HackerOne.
The vendor has since changed the email-search behavior, and the specific matching behavior described in this article no longer reproduces against the current build.
The vendor's name is intentionally withheld while the remaining issue is being addressed.
All testing was performed within the scope of the authorized bug bounty engagement.
Final Thoughts
One of the interesting things about application security research is that vulnerabilities don't always come from complicated exploitation chains.
Sometimes, they come from two individually ordinary features interacting in an unexpected way.
In this case:
- A hidden GraphQL query exposed an organization directory.
- A privacy restriction prevented direct email retrieval.
- A search operation evaluated the protected email column.
- SQL wildcard behavior made that search operation an information oracle.
The application never directly returned the sensitive information.
It simply answered enough questions to make that information recoverable.
And that's something worth remembering when testing modern applications:
Don't just investigate what an application tells you. Investigate what its behavior allows you to figure out.
— x71n0