← Back to blog

Domain Masking Without the Iframe: How Modern SaaS Does White-Label URLs

Jonathan Geiger·
tutorialdomain maskingwhite-labelsaasiframe

If you've ever Googled "domain masking" you've seen the same legacy advice: use an iframe. Wrap the destination site in an <iframe> on the source domain, the address bar stays put, you're done.

It "works" for the first 30 seconds of testing. Then you realize the iframe approach broke your JS app, made the page unindexable, and stopped working on iOS Safari last quarter. This post is what to do instead.

What masking is supposed to deliver

The product requirement is straightforward: the visitor types shop.acme.com, sees content from your origin, and the address bar stays as shop.acme.com for their entire session.

This is the right call for white-label SaaS, multi-tenant storefronts, creator pages, custom-branded help centers — anywhere your customer's domain is what should be visible, not yours.

The wrong implementation: iframe. The right implementation: server-side reverse proxy at the edge.

Why iframes fail

It's worth being specific about what breaks:

SEO is dead. Google indexes the source page. Source page contains an iframe pointing at your origin. Google sees an iframe element. The actual content lives on a different hostname, behind a frame. Indexing splits across two domains, neither ranks well for the content.

JS context is split. Your React app inside the iframe runs on yourplatform.com, not shop.acme.com. window.location.host returns the wrong value. Cookies set inside the iframe are third-party cookies (browsers block these by default now). Auth, analytics, anything tied to the hostname breaks.

Top-level navigation is broken. Click a link inside the iframe that opens an external URL? It opens inside the frame unless you've added target="_top" everywhere. Modal dialogs that try to use the full viewport are stuck inside the frame's bounds.

Mobile is unreliable. iOS Safari and Chrome on Android have been progressively restricting iframes for security. Full-screen video, payment APIs, Web Share, push notification permissions — all behave differently or fail outright inside frames.

Back button doesn't work. Navigation inside the iframe doesn't update the parent URL or contribute to browser history. Hit back, you leave the entire site instead of going to the previous iframe state.

You can patch around individual problems (postMessage, careful target attributes, third-party cookie workarounds). None of those fix all of them. The architecture is just wrong.

The right approach: reverse-proxy at the edge

The fix is to do server-side what iframes were trying to do client-side. The visitor's browser opens a TLS connection to your customer's hostname; that connection terminates at our edge; our edge forwards the request to your origin; the response comes back as a single HTTP document on the customer's hostname.

[ visitor: https://shop.acme.com/products ]
              |
              v TLS handshake (cert for shop.acme.com)
       [ Domainee edge ]
              |
              v forwards request to origin
              |
              |   X-Domainee-Original-Host: shop.acme.com
              |   X-Forwarded-Proto: https
              |
       [ your origin: app.yourplatform.com ]
              |
              v responds normally
              |
       [ edge streams response back to visitor ]
              |
              v
       [ visitor sees: shop.acme.com/products ]

From the browser's perspective, there's a single HTTP document at shop.acme.com. From your origin's perspective, you got a normal request with one extra header telling you the original hostname. From Google's perspective, it crawls shop.acme.com and indexes the real content under that domain.

None of the iframe problems exist. No frame, no split context, no third-party cookies, no back-button weirdness.

Tutorial: register a hostname in masking mode

One API call:

$ curl https://api.domainee.dev/v1/domains \
    -H "Authorization: Bearer sk_live_…" \
    -d '{
      "hostname":  "shop.acme.com",
      "originUrl": "https://app.yourplatform.com",
      "mode":      "proxy",
      "keepHost":  true,
      "metadata":  { "tenantId": "acme-42" }
    }'

The two fields that matter:

  • mode: "proxy" — reverse-proxy mode, address bar stays on the customer's hostname.
  • keepHost: true — preserve the original Host header when forwarding to your origin. This is what lets your origin tell which tenant the request is for.

Response includes the CNAME your customer adds. After they save it, TLS provisions on our side automatically. Domain goes live in about a minute.

What your origin sees

When a request arrives, your origin gets:

GET /products HTTP/1.1
Host: app.yourplatform.com
X-Domainee-Original-Host: shop.acme.com
X-Forwarded-Host:         shop.acme.com
X-Forwarded-Proto:        https

Read X-Domainee-Original-Host to know which customer's storefront is being served. One middleware:

export async function tenantFromRequest(req: Request) {
  const host =
    req.headers.get("x-domainee-original-host") ??
    req.headers.get("host")!;
  return db.tenants.findOne({ hostnames: host });
}

Cache the lookup. Hostnames don't change minute-to-minute; cache for an hour and invalidate on domain.created / domain.deleted webhooks.

What changes in your JS

Mostly nothing. The browser is genuinely loading shop.acme.com/products as a first-class document. window.location.host returns shop.acme.com. fetch("/api/cart") resolves to shop.acme.com/api/cart (which Domainee proxies back to your origin). Cookies set on shop.acme.com are first-party.

A few things to be aware of:

Absolute URLs in your HTML. If your app emits links to https://app.yourplatform.com/..., those land on your platform's hostname, not the customer's. Usually correct (for signup pages, dashboard links). Wrong if you meant "current site."

OAuth callbacks. If you initiate OAuth from inside the masked site, the redirect URL needs to be configured for the customer's hostname (or your platform's, depending on how you handle the callback). Most teams initiate OAuth from a known callback URL on the platform domain and bounce back to the customer's domain at the end.

Hard-coded CORS origins. If your origin's CORS policy allowlists https://app.yourplatform.com, requests will start failing when they arrive from https://shop.acme.com. Add a wildcard pattern for customer hostnames or scope CORS by the actual incoming origin.

Apex domains, WebSockets, streaming

Three things that nominally complicate masking and don't really:

Apex domains. acme.com itself, not shop.acme.com. CNAMEs at the apex are technically against spec but most registrars support ALIAS / ANAME / Cloudflare CNAME-flattening. Where they don't, A records work. The Domainee API returns both options in the dnsRecords response.

WebSockets. Fully proxied. Your origin's wss:// endpoint receives the same X-Domainee-Original-Host header as HTTP requests. Connection lifetime is whatever your origin negotiates.

Streaming responses (chunked, SSE). Fully supported. The edge streams responses through without buffering. Same goes for large file uploads.

When NOT to mask

Masking is the right answer when the customer's URL needs to be the visible one. It's the wrong answer when:

  • You're consolidating. Old domain → new domain after a rebrand. That's forwarding (301), not masking. See Domain Forwarding API.
  • You're moving a campaign URL. promo.acme.comacme.com/spring-2026. That's a 302 redirect, not masking.
  • You need different content per hostname but identical SEO indexing. Then you're not actually masking — you're hosting two distinct sites under two hostnames, and the right answer is two real deploys.

For everything else — multi-tenant SaaS, white-label products, custom-branded surfaces — masking via edge proxy is the move.

Ship it

The Domain Masking API is one POST per customer hostname, automatic TLS, full reverse-proxy, no iframe baggage. 50 hostnames free, no card. The first goes live in about a minute.

Domain Masking Without the Iframe: How Modern SaaS Does White-Label URLs | Domainee