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.


Consent screen does not match the OAuth scope
The “Zomato MCP” consent screen lists four human-readable capabilities:
- Access your name and email address
- View your orders, preferences, and account details
- Place orders and manage your cart on your behalf
- 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.

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.

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
scopevalue 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:toolsyields 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
- Enforce scope at the MCP application layer. Map
mcp:toolstotools/*,mcp:resourcestoresources/*,mcp:promptstoprompts/*. Return 403 for methods outside the granted scope. Return the honored scope string in the/tokenresponse. - Alternatively, if scope granularity is not desired, remove
mcp:tools,mcp:resources,mcp:promptsfrom the discovery document so the advertised contract matches runtime. - Shorten access-token lifetime to minutes; use refresh tokens for renewal.
- 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
- RFC 6749 Section 3.3 (scope parameter): https://datatracker.ietf.org/doc/html/rfc6749#section-3.3
- RFC 7591 (dynamic client registration): https://datatracker.ietf.org/doc/html/rfc7591
- Model Context Protocol specification: https://modelcontextprotocol.io/specification
- OAuth 2.1 draft: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1
- Ory Hydra documentation: https://www.ory.sh/docs/hydra/