Checked on 2026-08-24.
You want somebody else's site in an iframe. It won't go: the response carries X-Frame-Options or a frame-ancestors directive, and the browser refuses to paint what it already downloaded. Someone suggests the obvious fix. Fetch the page on a server you control, delete the two lines, serve the result from your own domain, and the browser has nothing left to enforce.
The first load works. Then the application wakes up, and every part of it assumes it's running on its own origin. Here's what breaks, in the order you'll hit it.
The recipe
A reverse proxy is a server of yours standing in front of somebody else's site.
- The browser asks your server for
https://proxy.example/app/inbox.
- Your server fetches
https://app.example/inbox.
- Your server deletes
X-Frame-Options and any frame-ancestors directive.
- Your server returns what's left, under your domain.
Both deletions matter, because sites don't agree on the mechanism. Among the 60 apps we requested on 2026-08-24, Outlook Web answered with frame-ancestors 'self' teams.microsoft.com and no X-Frame-Options, while Fastmail sent both X-Frame-Options: DENY and frame-ancestors 'self'. X-Frame-Options and frame-ancestors cover the difference; can I embed this site in an iframe? has all 60 rows: 7 allowed, 53 refused.
Why the first load looks like a win
Neither header does anything to a server. They instruct a browser about a document it already received, and your proxy isn't a browser. It's an HTTP client with no frame to protect and no policy engine. X-Frame-Options: DENY arrives, your code ignores it, and the header's authority ends there.
The browser then receives a document from proxy.example with no framing headers, so it paints. Relative URLs resolve against the proxy's address and come back through the proxy too. For a static page that is the whole job.
Then somebody signs in. Your server made that request with no session, so what's in the frame is a sign-in page — the same thing our unauthenticated measurement caught at Google.
The login leaves the frame
Say the app signs people in with OAuth. The browser visits an authorization server and comes back to a redirect URI registered in advance: a literal string on the app's domain.
RFC 6749, the OAuth 2.0 Authorization Framework, requires that where a client registered the full redirection URI, "the authorization server MUST compare the two URIs using simple string comparison." RFC 9700, the OAuth 2.0 security best current practice from January 2025, hardens that: authorization servers "MUST utilize exact string matching except for port numbers in localhost redirection URIs of native apps." Your proxy's address isn't a near miss. It's a different string.
Asking politely doesn't help, because RFC 6749 says the server "MUST NOT automatically redirect the user-agent to the invalid redirection URI." The browser leaves your frame for the real domain, often for a host you never configured. Of the same 60 apps, 12 finished on a host we had not asked for, and five of those on a dedicated accounts. host: Gmail, Google Calendar, Google Docs, Google Keep, and Evernote.
The cookie lands on the wrong host
Proxy the login too, and the app's Set-Cookie reaches your server. What happens next turns on one attribute, and neither branch helps.
A cookie that names the app's scope is rejected. The cookie draft draft-ietf-httpbis-rfc6265bis says "The user agent will reject cookies unless the Domain attribute specifies a scope for the cookie that would include the origin server," so Domain=app.example from your host is discarded. A cookie with no Domain is accepted, and the draft says where it lands: "the user agent will return the cookie only to the origin server." The browser files it under proxy.example, the host it's talking to. That's you.
A __Host- prefixed session cookie is built around that. The prefix means the cookie was set with Secure, a path of /, and no Domain attribute at all, and the draft gives the reason: "The lack of a Domain attribute ensures that the cookie's host-only-flag is true, locking the cookie to a particular host, rather than allowing it to span subdomains."
You're not that host: the session is now a cookie belonging to your domain. Why a site logs you out inside an iframe covers the browser-side rules.
Absolute URLs go straight past you
A rewriter has to catch every URL in the response, and it won't. Relative paths are free; absolute ones are not. https://app.example/api/me, written into the markup or baked into a bundle, sends the browser to the real origin, around your proxy. So you start rewriting: href and src, then srcset, then url() in stylesheets, then URLs inside JSON and the bundle. The first traps are usually not in the body: a Location header on a redirect, a Link header, and a <base href> that re-points every relative URL at the origin.
The bundle is where it ends. Applications build request URLs at runtime, sometimes from a value the server sent as data, and a rewriter reading bytes can't tell which string is about to become a URL. One rewritten wrongly breaks what worked.
The app's own API refuses your origin
Leave the API calls alone and a script on proxy.example ends up calling https://app.example/api/. That call is cross-origin, and CORS is keyed to who's asking. In the Fetch Standard's words, "A CORS request is an HTTP request that includes an Origin header," and that header "indicates where a fetch originates from." Yours says https://proxy.example. The API answers with an Access-Control-Allow-Origin naming its own origin, or with nothing, and the browser withholds the response.
Wildcards don't rescue this. Fetch's table of legal combinations rules out Access-Control-Allow-Origin: * whenever a request carries credentials, as a session request does. Fetch is blunt about the neighborhood: "Generally speaking, both sharing responses and allowing requests with credentials is rather unsafe, and extreme care has to be taken to avoid the confused deputy problem."
So you proxy the API too. Notice the pattern: every repair widens the proxy.
The WebSocket refuses it too
Live applications open a WebSocket, which carries the same Origin header. RFC 6455 is direct: "Servers that are not intended to process input from any web page but only for certain sites SHOULD verify the Origin field is an origin they expect." The alternative is spelled out: "If the server does not validate the origin, it will accept connections from anywhere."
Your proxy's origin isn't on that list. Route the socket through your server too and the check goes quiet, but read what you have turned into. The protection assumes a browser fills the header in, and Section 10.1 names the exception: hosts that use the protocol directly "are acting on their own behalf and can therefore send fake Origin header fields, misleading the server." That's the mechanism you'd be leaning on.
The header you can't strip
Sec-Fetch-Dest is not a response header. The Fetch Metadata Request Headers specification defines it on the way in: it "exposes a request's destination to a server." A cross-site frame load arrives carrying Sec-Fetch-Dest: iframe, and a server that reads it answers 403. You cannot delete your way to a body that was never written. The headers that tell a server it is being framed has the rest.
Here's the honest limit, and it's the WebSocket limit again. A proxy sits on the request side, so it can send Sec-Fetch-Dest: document and claim the load is an ordinary visit. What it can't do is strip its way there: beating a request-side check means asserting something false about how the request was made, which is a different act from deleting a restriction out of a reply.
Everything now flows through you
Add up the repairs. The page, the API, the sockets, and the sign-in all pass through your server — so do the password, the second factor, the session cookie, and every document anybody opens. TLS terminates at your edge, so all of it is plaintext in your process, your logs, and your cache.
The person using it sees your domain and your padlock. Every cue the browser gives about who they're talking to now describes you, and the application has no idea you exist — which is what the visual defenses against clickjacking rest on.
The protections you stepped over exist to keep this arrangement out. Host-locked cookies, exact redirect URIs, and origin checks all stop a third party from sitting inside somebody's session, and here you are the third party. Intent is not the point: a bug in your proxy is a credential breach in an account you don't own.
What the paperwork says, and why I won't tell you
I'm naming nobody here, and the reason matters. I did not survey anybody's terms of service for this article, so "most services prohibit this" would be a claim about documents I have not read — and a confident wrong claim about a real company's contract is worse than none.
What I can describe is the shape such clauses take. Agreements for hosted software commonly separate two questions: who may access the service and by what means, automated ones included; and how it may be displayed, framed, or resold elsewhere. A proxy touches both.
robots.txt won't settle it either. RFC 9309 aims that file at "automatic clients known as crawlers," and it never defines the word: its one descriptive line is "Crawlers are automated clients," with search-engine traversal offered as an example, not a limit. So a per-request proxy is not obviously in scope, or out of it. The standard settles it anyway by disclaiming its own weight: "These rules are not a form of access authorization."
So read the terms for the service in front of you — the words about access, automated means, framing, redistribution — and ask a lawyer if anything depends on it. I am describing what kinds of documents exist, not what is lawful.
When a reverse proxy is exactly right
None of this makes reverse proxies suspect. In front of your own application one is the normal front door: TLS termination, path routing, caching, rate limits. Nothing gets bypassed, because you own both ends, and if you want the result framed you set frame-ancestors yourself. The same holds for an organization's access gateway: applications it owns, accounts it issues, domains it controls.
The test is ownership, three ways: the application, the domain, and the users. Miss one and the proxy arranges something the missing party never agreed to. When the site belongs to somebody else, the supported route is the one they published: an embed endpoint that ships with no framing headers. Where there is none, the honest answer is a link that opens the real app in its own tab.
Questions
The page renders through my proxy. What am I missing?
Sign in and try to do something. An anonymous first load exercises none of this: no cookie scope, no OAuth round trip, no credentialed API call.
Can I rewrite every URL in the response instead?
Not reliably. Markup, stylesheets, and JSON are tractable. URLs assembled at runtime inside a bundle aren't, because you'd need to know which strings become URLs first.
Does a proxy get past a Sec-Fetch-Dest check?
Not by stripping anything: the check is on the request, so there's no response to edit. A proxy can send a false Sec-Fetch-Dest value instead, which works in the sense that lying works.