home / blog

I Found a Boring Frontend Key. It Turned Into OAuth Token Forgery.

May 28, 2026

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.

The target looked normal

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.

The first strange request

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.

The question that made the bug interesting

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?

The token endpoint believed the row

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.

Why OAuth bugs are easy to underestimate

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.

Then MCP made the token matter

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 part that made it click

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.

The public key was never the punchline

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.

The mental model I kept after this

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.

What happened after reporting

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 takeaway

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.