Cloudflare:IframeOnlyRedirect
lastmod 2026-10-28created 2026-10-281 edits
How to make an app deployed to a subdomain with Cloudflare Pages/Workers work only inside an iframe on a page of the main site. Opening it directly from the address bar 302-redirects to the corresponding page on the main site, and only allowed sites can embed it in an iframe.
About
- Example: a game such as
anitack.unwiki.netis shown in an iframe on thehttps://www.unwiki.net/games/anitack/page.- Opening
https://anitack.unwiki.net/directly → 302 tohttps://www.unwiki.net/games/anitack/. - iframes on
www.unwiki.netandunwiki.network as before. - If another site embeds it in an iframe, the browser blocks it.
- Opening
- Redirect: 302 only when the request header
Sec-Fetch-Destisdocument. - Embed restriction: response header
Content-Security-Policy: frame-ancestors ....
How it works
Sec-Fetch-Dest
The browser sends what a request will be used for in the Sec-Fetch-Dest header.
| Access method | | Handling |
| Address bar input, link click, bookmark, new tab | | 302 redirect |
| <iframe src=...> | | Respond as is |
| JS, wasm, images, | | Respond as is |
| Old browsers that don't send the header (Safari below 16.4), bots | (none) | Respond as is (fail-open) |
- Since it redirects only when the value is exactly
document, resource requests and iframes are not affected. - It does not decide based on
Referer. It gets dropped depending on privacy settings, and cannot distinguish an iframe from arriving by clicking a link on the main site. - Because the same URL responds differently depending on the header, add
Vary: Sec-Fetch-Dest, and use 302 instead of 301, which is cached permanently.
frame-ancestors
The redirect alone cannot block iframes on other sites (because they send Sec-Fetch-Dest: iframe). Add the following header to the response.
-
X-Frame-Optionsis not used because it can list only one allowed origin. -
'self'is kept for cases where the app uses iframes from its own origin. -
frame-ancestorsdoes not work with <code><meta></code> CSP. It must be sent as a response header.
Preserving path and query (?path=)
Pass along the opened URL so that invite/share links (/play?room=…, /join/ABCD?via=qr) don't break.
- If the path is
/, use only the page URL; otherwise append?path=<encodeURIComponent(pathname + search)>.
#…) never reaches the server, but if Location has no hash, the browser carries the original hash over to the redirected URL. ?path= and the hash to the iframe URL (see Parent page). Cloudflare Pages
functions/_middleware.ts
Place it at the repository root (not the build output directory). Pages compiles it automatically after the build.
/** 게임을 품은 위키 문서. */
const GAME_PAGE = 'https://www.unwiki.net/games/anitack/';
/** 이 게임을 iframe 으로 품어도 되는 곳. */
const FRAME_ANCESTORS = "frame-ancestors 'self' https://unwiki.net https://www.unwiki.net";
/** Pages 가 넘기는 문맥 중 쓰는 것만 — @cloudflare/workers-types 없이 둔다. */
interface PagesContext {
request: Request;
next: () => Promise<Response>;
}
/** 맨 위 창으로 연 문서 요청이면 위키 주소, 아니면 null. */
export function wikiRedirect(request: Request): string | null {
if (request.headers.get('Sec-Fetch-Dest') !== 'document') {
return null;
}
const {pathname, search} = new URL(request.url);
const path = pathname + search;
return path === '/' ? GAME_PAGE : `${GAME_PAGE}?path=${encodeURIComponent(path)}`;
}
export async function onRequest({request, next}: PagesContext): Promise<Response> {
const location = wikiRedirect(request);
if (location !== null) {
return new Response(null, {
status: 302,
headers: {Location: location, Vary: 'Sec-Fetch-Dest'},
});
}
const response = await next();
// 받은 응답의 헤더는 불변일 수 있다 — 베껴서 고친다.
const headers = new Headers(response.headers);
const csp = headers.get('Content-Security-Policy');
headers.set('Content-Security-Policy', csp ? `${csp}; ${FRAME_ANCESTORS}` : FRAME_ANCESTORS);
headers.append('Vary', 'Sec-Fetch-Dest');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
public/_routes.json
Without it, every request (including hashed bundles and icons) goes through Functions and uses up the free quota (100,000 per day). Exclude static files and send only page paths to the function. With Vite, placing it in public/ copies it to the build output (dist/).
{
"version": 1,
"include": ["/*"],
"exclude": ["/assets/*", "/favicon.svg", "/icon-192.png", "/site.webmanifest"]
}
- Deep SPA paths (
/play,/join/…) are also documents, so keepincludeas/*and remove static files withexclude. - Up to 100 rules in total, each up to 100 characters.
- When you add a new static file to
public/, add it toexcludetoo. It still works if you forget, but function invocations increase, and opening that file from the address bar gets redirected.
Caveats
- Don't add CSP with
_headers. According to the Cloudflare documentation,_headersis not applied to responses generated by Pages Functions. Add CSP in the middleware. (Use_headersonly for static files, such as cache rules.) - Deployment methods
- GitHub-integrated automatic builds,
wrangler pages deploy:functions/is included automatically. - Direct Upload (drag and drop) in the dashboard: does not support Functions.
- GitHub-integrated automatic builds,
functions/ is relative to the Root directory in the Pages build settings (default is the repository root). wrangler.json(c) has no pages_build_output_dir (i.e., it's a Workers config), the Pages build ignores that file. However, local wrangler pages dev reads it and the results may differ, so in that case copy only functions/ and the build output to a different directory to check. *.pages.dev URL and preview deployments (<branch>.<project>.pages.dev) are redirected the same way. Check previews with curl. wrangler pages download config<project> and looking at pages_build_output_dir. Cloudflare Workers (Static Assets)
functions/ is ignored. Do the same thing in the Worker code.
wrangler.jsonc
By default static assets don't go through the Worker, so add the page paths to run_worker_first. Don't include hashed bundles, icons, or sw.js (request quota).
{
"name": "std",
"main": "src/server/index.ts",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": ["/api/*", "/", "/index.html"]
}
}
Worker
export default {
async fetch(req, env) {
const {pathname} = new URL(req.url);
if (pathname.startsWith('/api/')) {
return handleApi(req, env); // 기존 API 는 그대로
}
const location = wikiRedirect(req); // Pages 와 같은 함수
if (location !== null) {
return new Response(null, {status: 302, headers: {Location: location, Vary: 'Sec-Fetch-Dest'}});
}
const res = await env.ASSETS.fetch(req);
const headers = new Headers(res.headers);
const csp = headers.get('Content-Security-Policy');
headers.set('Content-Security-Policy', csp ? `${csp}; ${FRAME_ANCESTORS}` : FRAME_ANCESTORS);
headers.append('Vary', 'Sec-Fetch-Dest');
return new Response(res.body, {status: res.status, statusText: res.statusText, headers});
},
} satisfies ExportedHandler<Env>;
- Responses from
env.ASSETS.fetch()come with the_headersrules (Cache-Control, etc.) attached. They are not attached to a 302 that the Worker creates itself. - Paths not in the assets (
/nope) also reach the Worker, so opening one directly redirects to?path=%2Fnope.
Service worker (PWA)
- With a service worker that handles navigation network-first (
fetch(event.request)), the server's 302 works as is. - Workbox
navigateFallback(the vite-plugin-pwa default) answers a returning visitor's navigation with the precachedindex.html, so it never reaches the server. Put a guard at the very start of the app boot.
// 운영 호스트를 맨 위 창으로 열었으면 위키로 옮기고 부트하지 않는다 (개발 서버·미리보기는 그대로)
if (window.top === window.self && location.hostname === 'texdun.unwiki.net') {
const path = location.pathname + location.search;
const page = 'https://www.unwiki.net/games/texdun/';
location.replace((path === '/' ? page : `${page}?path=${encodeURIComponent(path)}`) + location.hash);
} else {
boot();
}
- An installed PWA (
start_url: "/") goes to the wiki when launched, so it effectively can no longer be used as a standalone app.
Parent page
The side that embeds the iframe (e.g., MkDocs's overrides/partials/embed.html). It restores ?path= and the hash to the iframe URL, and discards values that leave the app origin (//evil.com, /\evil.com, https://…). Queries in the default src (embed=1, lang=ko, etc.) are re-added if missing from the passed URL.
<iframe id="uw-embed-frame" src="https://sphm.unwiki.net/" title="SPHM"
allow="fullscreen; autoplay; gamepad; clipboard-write; accelerometer; gyroscope; magnetometer; screen-wake-lock; camera; web-share"
allowfullscreen loading="eager"></iframe>
<script>
(function () {
var frame = document.getElementById("uw-embed-frame");
var path = new URLSearchParams(location.search).get("path");
if ((!path && !location.hash) || (path && path.charAt(0) !== "/")) return;
var base = new URL(frame.src);
var target = new URL((path || base.pathname + base.search) + location.hash, base);
if (target.origin !== base.origin) return;
base.searchParams.forEach(function (value, key) {
if (!target.searchParams.has(key)) target.searchParams.set(key, value);
});
frame.src = target.href;
})();
</script>
- A cross-origin iframe can use only the features listed in
allow. List every feature the app uses, such as sensors (accelerometer,gyroscope,magnetometer),screen-wake-lock,camera(QR scanning), andweb-share. - Don't add an "Open in new tab" link, since it would just redirect back to this page.
- Subdomains are the same site (eTLD+1), so cookies (
SameSite) and storage inside the iframe behave the same as when opened directly.
Sending each path to a different page
When each path has its own page, as with a tool collection, choose the page by the first path segment.
| URL opened directly | Redirect |
| | |
| | |
| | |
| | |
const TOOLS_PAGE = 'https://www.unwiki.net/tools/';
const SLUG = /^[a-z0-9-]+$/;
export function wikiRedirect(request: Request): string | null {
if (request.headers.get('Sec-Fetch-Dest') !== 'document') {
return null;
}
const {pathname, search} = new URL(request.url);
const slug = pathname.split('/')[1];
if (!SLUG.test(slug)) {
return TOOLS_PAGE;
}
const page = `${TOOLS_PAGE}${slug}/`;
const path = pathname + search;
return path === `/${slug}` ? page : `${page}?path=${encodeURIComponent(path)}`;
}
Verification
# 직접 연 문서 → 302, Location 에 위키 주소, Vary: Sec-Fetch-Dest
curl -sI -H 'Sec-Fetch-Dest: document' 'https://sphm.unwiki.net/join/ABCD?via=qr'
# iframe·헤더 없음 → 200 + content-security-policy: frame-ancestors ...
curl -sI -H 'Sec-Fetch-Dest: iframe' https://sphm.unwiki.net/
curl -sI https://sphm.unwiki.net/
# 정적 파일 → 리다이렉트·CSP 없음 (_routes.json 의 exclude)
curl -sI -H 'Sec-Fetch-Dest: document' https://sphm.unwiki.net/favicon.svg
- In a browser, create and insert an iframe from the developer tools console on another site (e.g., example.com) and you'll see the blocked screen.
- Passing
?path=on the parent page happens in the browser, not on the server, so actually open it and check the iframe'ssrc.
Limitations
- Browsers and bots that don't send
Sec-Fetch-Destare not redirected. To keep bots from indexing the app URL on its own, considerX-Robots-Tag: noindex, indexifembedded. - If you run the parent site locally (
localhost), the iframe is blocked because it's not inframe-ancestors. Addhttp://localhost:*if needed. - Local
wrangler devorpages devalso redirects when opened as the top-level window. Embed it in an iframe or check with curl. - Inside a cross-origin iframe, some features such as vibration (
navigator.vibrate) may be blocked depending on the browser.
Alternatives
- Zone rules: A Redirect Rule in the same zone (
unwiki.net) (conditionany(http.request.headers["sec-fetch-dest"][*] eq "document"), targetwildcard_replace(http.request.full_uri, "https://*.unwiki.net/*", "https://www.unwiki.net/games/${1}/")) + a Response Header Transform Rule can handle it without code. It requires dashboard settings, cannot block*.pages.dev, and you need to check in the expression editor whether request header fields can be used in Redirect Rules. - Client only:
location.replace()whenwindow.top === window.self. You must change the code of each app, and the screen briefly appears before moving on. Use it only as a supplementary measure for apps with a service worker.
See also
- Cloudflare:Pages
- Cloudflare:Workers
- Content-Security-Policy
- iframe
- Service Worker
- Progressive Web App
Favorite site
- Sec-Fetch-Dest - HTTP | MDN
- CSP: frame-ancestors - HTTP | MDN
- Get started · Cloudflare Pages Functions
- Routing (_routes.json) · Cloudflare Pages Functions
- Headers (_headers) · Cloudflare Pages
- Single Redirects · Cloudflare Rules
- Robots meta tag (indexifembedded) · Google Search Central