Introduction

This post documents a finding against the Model Context Protocol server at https://mcp-server.zomato.com, listed as a Tier 1 asset on Eternal Limited’s HackerOne program (Eternal is Zomato’s parent). The OAuth 2.0 authorization server silently rewrites the scope parameter at /authorize, issues access tokens labeled scope: "offline openid", and then allows those tokens to invoke every advertised MCP tool, including checkout_cart, create_cart, bind_user_number, and get_saved_addresses_for_user. The mcp:tools, mcp:resources, and mcp:prompts scopes advertised in /.well-known/oauth-authorization-server are never enforced.

Product: Zomato MCP Server, reported version ZomatoMcpServer/3.1.0 CWE: CWE-863 (Incorrect Authorization), CWE-285 (Improper Authorization) CVSS 3.1: 7.3 High, AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N GitHub: zomato-mcp-oauth-scope-not-enforced

Why this target

Eternal’s program added mcp-server.zomato.com/mcp in September 2025 at the top bounty tier. MCP is a relatively new protocol, most bounty hunters have not studied it yet, and the endpoint exposes real money-moving tools against an authenticated Zomato account. That combination of novel protocol plus first-party AI integration plus real-world side effects made it the most interesting asset on the program.

Unauthenticated fingerprinting

Three discovery documents were reachable without auth. Two of them disagreed with each other:

Endpoint scopes_supported
/.well-known/oauth-authorization-server ["mcp:tools", "mcp:resources", "mcp:prompts"]
/.well-known/openid-configuration ["openid", "offline"]
/.well-known/oauth-protected-resource (similar to the auth-server doc)

Two different scope contracts from the same server is a red flag. It was the first sign that the advertised scope story might not line up with runtime behavior.

Server banner: uvicorn (Python ASGI) behind Akamai Bot Manager. Anonymous requests to /mcp returned WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required".

The scope rewrite

I built an authorization request with the MCP scopes from the auth-server discovery doc:

GET /authorize?client_id=fd37dd28-254b-42b7-a55a-c85369d625c8
  &response_type=code
  &redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback
  &scope=mcp%3Atools%20mcp%3Aresources%20mcp%3Aprompts
  &state=<valid-8+chars>
  &code_challenge=<S256-challenge>
  &code_challenge_method=S256

The server responded HTTP 307 with a Location pointing at:

./consent?login_challenge=<...>&scope=offline+openid&client_id=<...>&redirect_uri=<...>&state=<...>

The requested mcp:tools mcp:resources mcp:prompts had been silently discarded and replaced with offline+openid. Any scope value I requested (single, multi, with every combination of the five advertised scopes) produced the same rewrite.

Browser address bar showing the outgoing request with scope=mcp:tools mcp:resources mcp:prompts

Browser address bar after the 307 redirect, showing scope rewritten to offline+openid

The “Zomato MCP” consent screen lists four human-readable capabilities:

  1. Access your name and email address
  2. View your orders, preferences, and account details
  3. Place orders and manage your cart on your behalf
  4. Access your delivery addresses and location data

These are broader than the OAuth scope shown in the URL (offline openid). openid under OIDC is only “basic identity”; offline just means “you may issue a refresh token.” Neither of those implies authorization to place orders or bind phone numbers. The user sees one thing in plain English, the machine sees another thing in the token claim.

Zomato MCP consent screen listing four capabilities including ‘Place orders and manage your cart on your behalf’, next to a URL bar showing scope=offline+openid

Token exchange

I approved consent on a personal Zomato account and caught the callback at http://localhost:8080/callback. The code exchange returned:

{
  "access_token": "gAAAAABp5XYNsBFfSVa0xioB9Rb_hvgWJNwGd-HH...",
  "token_type": "Bearer",
  "expires_in": 2591999,
  "refresh_token": "U1b7iL2k9jtsdGwtSIdqmh70S--ZlAzy7jWR-PVYU6M...",
  "scope": "offline openid"
}

Three observations:

  • scope: "offline openid": the requested MCP scopes are not present on the issued token.
  • expires_in: 2591999 (about 30 days): unusually long for an OAuth access token. Standard practice is minutes-scale access tokens with refresh-token renewal.
  • Token format gAAAAABp... is Fernet-style (cryptography.fernet), consistent with a Python backend.

The token has full MCP capability despite “offline openid” scope

MCP initialize returned ZomatoMcpServer/3.1.0 with full tools, resources, and prompts capabilities announced. tools/list returned 11 callable tools, all invokable by the offline openid-scoped token:

Tool Effect
get_restaurants_for_keyword restaurant search
get_restaurant_menu_by_categories read menu
get_menu_items_listing read menu
get_saved_addresses_for_user read PII (user’s delivery addresses)
bind_user_number writes, binds phone number to account
bind_user_number_verify_code writes, completes phone binding
create_cart writes, stages cart with items, address, payment method, promo
checkout_cart writes, places a real order
get_order_history read order history
get_order_tracking_info read active orders
get_cart_offers read promos

No 403 was ever returned despite the token lacking mcp:tools, mcp:resources, or mcp:prompts scope. The scope parameter is cosmetic.

Terminal capture: token response showing scope ‘offline openid’ on the left, tools/list returning 11 callable MCP tools on the right

Root cause

The /authorize to /consent?login_challenge=<hex> pattern, the separate /token and /register endpoints, and the error wording all match Ory Hydra (or a close fork). The likely misconfiguration: Hydra is set up with only openid and offline as issuable scopes, while the MCP application layer behind it does not verify the issued scope list at all. Any valid Bearer token is treated as fully authorized for every MCP method. The mcp:* scopes advertised in the discovery doc are not wired to enforcement anywhere.

Impact

  • Scope-based least-privilege is entirely broken on this server. Any relying client, integration, or audit pipeline that inspects the scope value to make authorization or flagging decisions is silently bypassed.
  • The advertised OAuth contract does not match runtime behavior. Integrators building against the discovery document reasonably assume that requesting mcp:tools yields a token restricted to tool invocations. They instead get a maximally-privileged token, labeled with a minimal scope.
  • 30-day access tokens inflate the blast radius of any token theft. Typical OAuth access token TTL is 5 to 60 minutes with refresh-token renewal.
  • Consent-screen text and token scope claim are incoherent. Users see “place orders on your behalf” in plain English; any machine consumer sees only offline openid.

Remediation suggestions

  1. Enforce scope at the MCP application layer. Map mcp:tools to tools/*, mcp:resources to resources/*, mcp:prompts to prompts/*. Return 403 for methods outside the granted scope. Return the honored scope string in the /token response.
  2. Alternatively, if scope granularity is not desired, remove mcp:tools, mcp:resources, mcp:prompts from the discovery document so the advertised contract matches runtime.
  3. Shorten access-token lifetime to minutes; use refresh tokens for renewal.
  4. Align consent-screen capability text with the scope returned in the token.

Vendor response

Reported to Eternal (Zomato parent) via HackerOne on 2026-04-19. Closed as Informative: “this bug does not pose an actionable impact and we do not see it as an immediate threat.”

Full PoC, screenshots, and end-to-end repro script available at the GitHub repository. All testing was performed on a personal Zomato account; no state-mutating MCP tool was invoked during the research.

References