home / blog
I almost skipped the finding.
It started with one of the most boring things you can find during web recon: a public key inside a JavaScript bundle.
If you have done bug bounty or web app testing, you already know how this usually goes. You open the bundle, see some API config, get excited for half a second, test the key, and realize it can only do what the frontend already does.
Most public keys are not secrets. They are labels. The backend decides what they can touch.
This key was different.
Instead of an admin panel or a dramatic database dump, the key led somewhere quieter: a browser role could reach the OAuth storage behind an MCP server.
That is where the story stopped being about a leaked key and started being about trust.
I started the way I usually start: collect public assets, open the main app, watch network traffic, download JavaScript, search for API hosts, then test what the browser already knows.
Nothing looked dramatic at first.
There was a web app. There were API calls. There was an MCP integration somewhere in the product. There was a browser-facing data API configuration in one of the shipped bundles.
I had seen this pattern many times. The public key usually has tight permissions. It might read a public profile row. It might create a signup request. It might write analytics events. Boring, but normal.
Still, the only way to know is to ask the role what it can do.
I tested a read against storage that looked related to OAuth refresh tokens.
That sounds scarier than it was at first. I expected hashes, because refresh tokens belong in storage the same way passwords do: transformed before they ever hit disk.
I only wanted to know if the table was visible at all.
GET /data/<refresh_token_store>?select=id,subject,scope,revoked_at&limit=1
Authorization: Bearer <public_browser_key>
HTTP/2 206
[
{
"id": "<row_id>",
"subject": "<tenant_and_user_context>",
"scope": "<oauth_scopes>",
"revoked_at": null
}
]
The server answered with a real row.
The raw token stayed hidden, but the browser role could read the ledger around it: subject context, scope, revocation status, and enough structure to understand what a valid grant looked like.
That was already a vulnerability. A public browser role had access to records that drive token refresh.
But read access was only the first door.
Read access gave me the small version of the story: a public role could inspect sensitive OAuth metadata.
Write access would turn that into something sharper.
Refresh-token storage is future authentication. If the token endpoint trusts that storage, writing a row there is closer to editing the backend's memory of who can refresh into a session.
So I tried a small proof write.
I generated my own test refresh token locally. I hashed it into the same kind of stored value the system expected. Then I attached it to an existing authorization context that the public API had already exposed.
POST /data/<refresh_token_store>
Authorization: Bearer <public_browser_key>
Content-Type: application/json
{
"token_hash": "<hash_of_my_test_token>",
"subject": "<existing_tenant_and_user_context>",
"scope": "<compatible_scope>",
"expires_at": "<future_time>",
"revoked_at": null
}
HTTP/2 201
[
{
"id": "<inserted_row>",
"subject": "<existing_tenant_and_user_context>",
"revoked_at": null
}
]
The write worked.
That was the first real "wait, what?" moment.
The browser role had just written refresh-token state. Now the bug had a clean test: would the OAuth server accept the matching token?
I sent the test refresh token to the real OAuth token endpoint.
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=<my_test_token>
&client_id=<matching_client>
HTTP/2 200
{
"access_token": "<fresh_bearer_token>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<rotated_refresh_token>"
}
That response changed the severity.
I wrote token state through a public role, then asked OAuth to process the matching token. The flow used my test token, attached to authorization context the public API had already exposed.
The OAuth endpoint saw a row in the right shape and minted a bearer token.
A public browser role could write the state that OAuth later treated as authority.
OAuth can make bugs look less direct than they are.
If an endpoint returns private data immediately, everyone understands the story. You send a request, data comes back, and nobody has to squint at the screenshot.
OAuth bugs often hide one layer earlier. The first response may only show that you can influence the thing that creates access.
That is what happened here.
The vulnerable write returned a database row. That row became useful when the token endpoint consumed it. The token became useful when the MCP server accepted it.
You had to follow the trust chain.
A bearer token is only interesting if something trusts it.
In this case, the token belonged to an MCP flow. MCP servers expose tools, and those tools can read, search, update, summarize, create, delete, schedule, send, or call into other systems depending on what the server offers.
I initialized the MCP server with the token.
POST /mcp
Authorization: Bearer <fresh_bearer_token>
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"clientInfo": { "name": "test-client" }
}
}
HTTP/2 200
{
"result": {
"serverInfo": { "name": "<mcp_server>" }
}
}
Initialization succeeded.
Then I called one read-only tool with a limit of one.
POST /mcp
Authorization: Bearer <fresh_bearer_token>
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "<read_only_listing_tool>",
"arguments": { "limit": 1 }
}
}
HTTP/2 200
{
"result": {
"items": [
{
"id": "<redacted>",
"title": "<redacted>",
"visibility": "<redacted>",
"updatedAt": "<redacted>"
}
]
}
}
That was enough.
One protected tool response was enough. The forged OAuth path had crossed into MCP capability.
The bug lived across a chain of normal-looking trust decisions:
public JavaScript
exposes browser data API config
↓
browser role
can read OAuth state
↓
browser role
can write refresh-token state
↓
OAuth endpoint
trusts refresh-token storage
↓
MCP server
trusts OAuth bearer token
↓
MCP tool
returns protected data
Each layer behaved like the previous layer had done its job.
The data API assumed the browser role had the right permissions. The OAuth endpoint assumed refresh-token storage contained trusted records. The MCP server assumed the bearer token represented a valid user context.
The failure lived between those assumptions.
It is tempting to title every finding like this "API key leaked in JavaScript."
That title misses the better story. The key was public by design. The vulnerability was what the key could do.
There is a big difference between:
public key can call public APIs
and:
public key can write auth state that becomes a bearer token
That second line is the whole story.
For MCP apps, I now trace authority backward.
I start at the tool and ask what had to be trusted for that tool call to work.
tool call
bearer token
refresh token
refresh-token row
database permission
frontend bundle
If a browser role can write any layer in that chain, the MCP server can look fine while the authority path behind it is broken.
I sent the vendor the private report with exact routes, table names, row counts, proof requests, cleanup notes, and the bounded MCP response.
They investigated, patched, and told affected users to reauthorize the MCP connection.
The bug started as a boring public key.
It became interesting only after I followed where that key led.
That is the lesson I would give anyone testing MCP or OAuth-heavy apps:
Do not stop at the frontend key. Follow it until you reach the thing that decides authority.
Sometimes the MCP server handles the final request, while the broken trust started three layers earlier in the table the token server trusts.