How to: Embed a LiveView via iframe

After collecting information from multiple sources (this forum, blogs, StackOverflow and GitHub), I was finally able to successfully embed part of an existing Phoenix LiveView app in an iframe.

I’d like to share the steps here, both for future reference and to hopefully help others. If you find this useful, please let me know. Do you think the official docs should contain a small guide on this topic?


Configuring a LiveView app to be embeddable via iframe

There are luckily only a few changes to be made to a freshly generated app. There are some tradeoffs and considerations to be made, and your requirements might differ from the decisions below.

This guide assumes you only need to expose part of your application, rendering unauthenticated pages (not depending on session cookies), and you want to keep the standard security measures in place.

There are only two parts, let’s begin.

LiveView Socket (endpoint.ex and app.js)

Some threads have suggested changing the default session cookie config from same_site: "Lax" to same_site: "None", secure: true, but that affects all LiveViews and other routes.

Instead, following a suggestion from Chris McCord, use a separate socket for iframe-originated WebSocket connections (and long poll fallback):

# file: my_app/lib/my_app_web/endpoint.ex

  # Default config:
  @session_options [
    store: :cookie,
    key: "_gpt_demo_key",
    signing_salt: "ZfIfSPDp",
    same_site: "Lax"
  ]

  socket "/live", Phoenix.LiveView.Socket,
    websocket: [connect_info: [session: @session_options]],
    longpoll: [connect_info: [session: @session_options]]

  # Add new socket specifically for embedded pages. This socket will not
  # Have access to session info, in particular won't be able to read information
  # stored in session cookies such as CSRF token and logged in user token.
  socket "/embed/live", Phoenix.LiveView.Socket, websocket: true, longpoll: true

Update my_app/assets/js/app.js to connect to the correct URL:

let socketUrl = window.location.pathname.startsWith("/embed/") ? "/embed/live" : "/live"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")

let liveSocket = new LiveSocket(socketUrl, Socket, {
  longPollFallbackMs: 2500,
  params: {_csrf_token: csrfToken}
})

Pipeline, layouts, routes (router.ex)

Create a new Plug pipeline for the routes that should be embeddable. Possibly not all pages of your app should be allowed within an iframe.

As we’re not passing session information to LiveView as per our Socket config earlier, make sure the root_layout and layout in use don’t depend on session information, for instance they do not refer to @current_user if you use mix phx.gen.auth.

You may want to create specific layouts for the embed pages, which might reuse components from the rest of the app.

The layout can be configured in the live_session declaration in router.ex (Live layouts — Phoenix LiveView v0.20.17).

Example, assuming you created a layout my_app/lib/my_app_web/components/layouts/embedded.html.heex:

# file: my_app/lib/my_app_web/router.ex

  # Default config:
  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  # Similar to default `:browser` pipeline, but with one more plug
  # `:allow_iframe` to securely allow embedding in an iframe.
  pipeline :embedded do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :allow_iframe
  end

  # Configure LiveView routes using the `:embedded` pipeline
  # and custom `embedded.html.heex` layout.
  scope "/embed", MyAppWeb do
    pipe_through [:embedded]

    live_session :embedded,
      layout: {MyAppWeb.Layouts, :embedded} do
      live "/:id", EmbeddedLive
    end
  end
  
  # A plug to set a CSP allowing embedding only on certain domains.
  # This is just an example, actual implementation depends on project
  # requirements.
  defp allow_iframe(conn, _opts) do
    conn
    |> delete_resp_header("x-frame-options")
    |> put_resp_header(
      "content-security-policy",
      "frame-ancestors 'self' https://example.com" # Add your list of allowed domain(s) here
    )
  end

For handling only GET requests, it is fine to keep the :protect_from_forgery plug. Other use cases, like requiring form submissions, login, etc, might require further changes.


After trying a few config changes, debugging infinitely reloading pages, and so on, the above is the minimal set of changes that got me to a working state.

If you got here, thanks for reading and hope it was helpful to you, happy coding :purple_heart:

12 Likes

Depending on why you are wanting to embed an iframe, using a custom element as the container for an embedded web app might be a better solution. There are a number of things you can do to better integrate with the surrounding site that just aren’t possible with an iframe. If you go this route, you might want to check out LiveState, as this is the problem it was designed to solve.

Full disclosure: I am the author of LiveState.

His Chris! I recall watching your LiveState presentation from ElixirConf 2022 and taking a look at the repo some time ago, nice work :clap:

My use case (and the other few threads I found on the forum) is about embedding parts of an existing LiveView app on a third-party where we have no control of technology stack.

Sorry for I don’t remember all the details, would LiveState fit that use case? In some circumstances, all I can count on is with a third-party copy-pasting an <iframe src="..." ...> line. In other cases, I may be able to provide an arbitrary <script> tag (primarily to dynamically create the iframe, but perhaps could be used for rendering a LiveState client side app?

It would fit, with some caveats. You would need to port your LiveViews to be LiveState channels instead. The front end templates would need to be rewritten as custom elements. Using a custom element in html requires a script tag to load the code that defines the element, but after that it is usable the same as any other html element, and can be styled with css to the extent you choose. This added flexibility may or may not be worth the level of effort over an iframe, it just depends on your specific situation.

1 Like

Thanks for this! Unfortunately I too need to embed an app inside an iframe, and it needs to be live view. I really appreciate the notes around scoping this to only part of the app. But I just can’t seem to get it working end to end.

I do like @superchris 's suggestion. But in my case I am building a “Zendesk Sever Side App” which is an iframe only situation, and of course I have little control over the iframing. All I can do is specify the “domainWhitelist” as Zendesk call it.

Maybe I can serve a static page that uses a live element. And iframe the whole kit and caboodle. :thinking: that will be my next attempt I think. I have been stuck on this for days now.

How did you solve the continuous reloading? and does your live reload on file change still work?

The best I can get to solve the infinite reloading was to modify session options. I wasn’t able to limit it to the /embed/live scope as you suggested.

  @session_options [
    store: :cookie,
    key: "_my_app_key",
    signing_salt: "xxxx",
    same_site: "None",# Added this
    secure: true # This too
  ]

And then I used mkcert to generate an SSL certificate (which adds a CA into the browser) and set up local dev to run https on 443. Finally I hacked my /etc/hosts file to give me https://myapp.mydomain.com along with PHX_HOST so the whole things smells legit to a browser.

That stopped the infinite reloads, but that darn live reload just doesn’t want to play ball. (outside the iframe it works just fine)
On a code change, the browser console shows:

frame:1615 Uncaught DOMException: Failed to read a named property 'reload' from 'Location': Blocked a frame with origin "https://myapp.mydomain.com" from accessing a cross-origin frame.
    at pageStrategy (https://myapp.mydomain.com/phoenix/live_reload/frame:1615:33)
    at https://myapp.mydomain.com/phoenix/live_reload/frame:1644:24

Which is perplexing because the iframe loads happily to begin with, but error’s on live reload.

I even tried my own own file watcher, that sends a socket message to a custom chrome extension which in turn locates the iframe and issues a reload. Absolute madness. Things got pretty messy. There has to be an easier way!

Would love to hear of any more learnings you may have had!

For the SSL issue, I would suggest trying ngrok. It works really well for when you are doing local development and need have something available externally over SSL. For the live reload thing, I might suggest getting everything working first outside of ZenDesk so you need less reloading during the dev process. Good luck!

Thanks @superchris ! yeah ngrok is a great piece of kit, good shout!

I managed to “roll my own” live reload that applies to pages that are embedded, and seems to work :crossed_fingers:

As you suggested I started working out of the zendesk environment, but didn’t get far. I need to interact with there javascript API’s quite a bit, but I seem to be out of a jam! Thanks again :slight_smile: