Securing iframed embed integrations
Some integrations between SaaS services involve
<iframe> elements used to embed content from one service into another. For example, you might want to embed some content from your service into Confluence, Notion, or other wiki software. Lets consider the different tools you could use to secure such an integration, and what their limitations are.
The actors in our play
First, here’s an overview of the different “actors” involved. I’ll use these terms in the rest of the article, so let’s define them up front.
- Your backend. This is your server. Whatever computers you control that deliver your frontend and user content to the browser.
- The browser. This runs your frontend and enables it to communicate with your backend, the host app, etc.
- The host app: the website that’s embedding your frontend in an iframe.
These actors also have different security properties, which will be important to keep in mind. Lets drill into that next.
Relationships of trust (or not trust)
Consider the relationships between the actors above, and the level of control you have over them. A common topic in web dev is the untrustworthiness of the frontend. Because it runs on the user’s machine, your backend can only give limited trust to your frontend, and only by using some kind of authentication. For example, if you have a logged-in user via cookies, the backend can probably trust the frontend to read and modify that user’s content. But you can’t rely on browser behavior to keep content away from the user; your backend has to do that itself. The frontend can’t protect you from the user, because the user can modify the frontend’s behavior or bypass the frontend altogether.
An embedded situation adds two more actors to consider: the host app and the browser. As far as your backend is concerned, trusting the browser has the same dangers as trusting the frontend: the user can easily modify or bypass browser behavior to suit their purposes. But the browser still plays an important security role in this scenario: it mediates the relationship between your frontend and the host app. The core of this is the same-origin policy, which means the browser doesn’t allow one site to access the data displayed on another site. So while you can’t use the frontend or browser to protect your backend from the user, you can use the browser and frontend to protect your user from other websites. This idea will be really important; we’ll see a few examples later on.
When is iframing safe?
First off, when is it even safe to iframe your app into some other website? This probably isn’t a comprehensive list, but here are the two main concerns I know of:
- The user input given to an iframed page should not be sensitive. For example, never ask the user for a password in an iframe. It trains the user to enter their password into sites that aren’t yours - to the user, it’s indistinguishable from typing your app’s password into the host app itself (which would be very bad!).
- Make sure clickjacking isn’t a concern, either because you trust the host app, or because the user interactions involved are too benign to care. A host app can do dirty tricks to try and (1) trick the user into unknowingly doing actions in your iframe or (2) figure out what the user is doing in your iframe.
Pages that shouldn’t be iframed should use CSP’s
frame-ancestors directive and (for older browsers) the
X-Frame-Options header to disallow iframing.
Now let’s jump in and consider different kinds of embedded integrations.
If you don’t care where your content is embedded
The simplest kind of embeds are ones that don’t care where they’re embedded. There are a few different kinds of these:
- Things that are public and embeddable, like public YouTube videos.
- Things that are “public” but unlisted, like public Google Docs. These embeds use some kind of unguessable URL. If you have the URL, you have access to the document, whether in an iframe or not.
- Anything cookie-authed and embeddable, like private Google Docs. You have to be logged in to Google and have access to the document to see the document contents, so the iframe is not much different from opening the doc in a normal browser tab. Miro’s direct link embeds fall into this category, too.
Note that, because these embeds don’t know anything about where they’re embedded, they have to be the kind of content where clickjacking isn’t a threat. For example, the worst a host site could do with an embedded YouTube video is probably tricking you into playing it (maybe to mess with your recommendation algorithm? idk). That’s apparently benign enough that YouTube doesn’t require the host site to authenticate itself. But if YouTube did want to know where it’s embedded, they’d have to use one of the following measures.
If your frontend wants to control where it’s embedded
If your frontend wants to control where it’s embedded, you can rely on the browser’s features to give you that control. This means using the referrer header, CSP’s
frame-ancestors directive, and/or
postMessage’s target origin to verify the embedding site. The same-origin policy is actually pretty powerful!
(Before you send me angry emails about suggesting using the referrer for anything security-related, keep reading. 🙂)
Here’s the important bit: these measures can only be trusted by the frontend. The backend can only trust these measures indirectly, to whatever extent it can legitimately trust the user and their browser. Put another way: the frontend can trust the browser by virtue of running in the browser, so if your backend has a reason to trust your frontend (e.g. an auth session), then the backend can trust the frontend which trusts the browser.
The backend can’t use these measures alone to protect the content it delivers. Anyone can spoof whatever frontend behavior they want to get the backend to deliver that content. (This is where referrer headers get a bad rep. Anyone can tamper with the headers their own browser is sending, including the referrer! So this is only useful for situations where you can trust the user and the browser.)
For example, the same-origin policy doesn’t do much to secure the contents of “public but unlisted” embeds, like shared Google Docs. Anything that has the URL - including the host app! - can access the contents in that Google Doc.
Side note: if you want to, you can use these frontend measures to prevent embedding a “public but unlisted” URL on the wrong site, which lets you vet sites before allowing them to embed stuff. (Miro’s LiveEmbeds and Dropbox’s Embedder both do this, I believe.) This helps prevent clickjacking and can discourage redistributing the embedded content. But frontend measures can’t prevent someone with the embedded URL from getting the actual content, or from giving that URL to other people.
When are frontend security measures useful? One such scenario would be a content picker in an iframe, such as Dropbox’s Chooser. Here’s an example of how this kind of thing might work:
- The host includes an “app ID” in the iframe URL query params, and sets
referrerpolicy="strict-origin"on the iframe.
- Your app verifies that the iframe referrer is in an allowlist for that host app. (The allowlist would be registered by the host app’s developers previously.)
- Now the frontend can trust that it’s embedded in the host app’s site and not some other site, which enables it to tell the user what’s going on (“Select a file to share with HostApp”).
- Your frontend uses the host app’s origin when sending
postMessageoutput, to ensure the messages only go to that origin.
In short, by relying on browser features, the frontend can determine where it’s embedded and then surface that information to the user or adjust it’s behavior if needed. (The frontend can even disable itself altogether if it doesn’t like to be iframed there, which is essentially what the “no iframing” headers mentioned above do.)
A quick note about these frontend security measures: some features, like the referrer header and
postMessage’s target origin, only let you constrain the origin of the parent frame. Others, like CSP’s
frame-ancestors or the non-standard
location.ancestorOrigins property, enable you to constrain the origins of the parent frame and all of its ancestor frames. These constraints have different pro’s and cons depending on what you’re using them for. For example, if you constrain only the parent frame, that can be circumvented by a host app wrapping your iframe in it’s own iframe that doesn’t enforce any embedding constraints. But if you constrain all ancestors, then your iframe might stop working if the host app builds its own embed integration into some other service.
Another interesting side note: OAuth in a popup works essentially the same way. The authorization server frontend trusts that the browser enforces the same-origin policy (otherwise the opener could programmatically accept the prompt, or steal the authorization code, or something else nefarious). But in order for the authorization server backend to be able to trust the frontend and browser, it needs to use some kind of authentication - which it does when the user logs in.
If your backend wants to control where it’s embedded
If your backend wants to deliver content only if it’s embedded in the right app, then you need the host app to somehow certify to your backend that the content should load there. This can’t rely on the same-origin policy, because your backend can’t rely on any browser or frontend behavior, as explained above. Someone can imitate whatever browser behavior they want to get around those restrictions.
As an example, this is necessary if you want to embed private content for anonymous viewing. The host page needs to send some kind of authentication (via query params, form post,
postMesage, etc.) that tells your backend that the content should load there.
For example, if you build a Confluence embed integration using Atlassian’s Connect framework, Atlassian can include a short-lived JWT in the iframed URL that asserts certain things about where your iframe is currently displayed. This is signed by Atlassian using a shared secret that only Atlassian and your backend know. This means it can’t be forged by a malicious frontend/browser/etc, which is important because your backend doesn’t have an established relationship of trust with the frontend regarding that content.
You might not need OAuth
An interesting thing I’ll point out here: none of these situations immediately require something like OAuth, because none of them require the host app to access resources on behalf of a user in your app. Even in the backend-control situation, all the host app needs to do is make some sort of secure statement about where it’s embedding that content. So if you’re building an embed API and immediately think “I need OAuth and REST”, think again. There’s a much simpler way to do some things. 🙂
Hopefully this overview gives you a better grasp on what security measures are available when building an integration using iframes, and when they should be used. I also hope you learned something about the different “actors” in a web security situation and what reasons they might have to trust (or not trust) each other; that mental framework has helped me solidify my intuition about many web security concepts.
❗ Disclaimer: This article is somewhat reductionistic, and might have some missing nuance or even misunderstandings on my part. Treat this as a high-level overview of these security concepts and not as a comprehensive, rigorous treatment of the subject. 🙂