Remix V4 Embedded App: accounts.shopify.com Refused to Connect Inside Iframe After Scope Changes or Missing Session

Remix V4 Embedded App: accounts.shopify.com Refused to Connect Inside Iframe After Scope Changes or Missing Session

Context

Tech Stack

  • Remix V6

  • @shopify/shopify-app-remix v4.1.0

  • Prisma (MySQL)

  • React 18

App Type

  • Embedded Shopify App

Shopify Configuration

future: {
  unstable_newEmbeddedAuthStrategy: true,
  expiringOfflineAccessTokens: true,
}

The Issue

We are encountering an accounts.shopify.com refused to connect” error inside the Shopify Admin iframe.

This happens in two scenarios:

  1. Fresh database / empty Session table

    • No Shopify session exists in our database.
  2. Scope mismatch

    • App scopes in shopify.app.toml were updated.

    • Existing merchant sessions in the database still contain the old scopes.

In both cases, the following loader executes:

export const loader = async ({ request }: LoaderFunctionArgs) => {
  await authenticate.admin(request);
  return json({
    apiKey: process.env.SHOPIFY_API_KEY || "",
  });
};

authenticate.admin(request) correctly detects that a valid session with the required scopes does not exist and attempts to redirect to /auth.

However, because this occurs while the app is loaded inside the Shopify Admin iframe, the browser ultimately tries to load accounts.shopify.com within the iframe and fails with:

accounts.shopify.com refused to connect

This appears to be caused by Shopify’s security headers (X-Frame-Options / Content-Security-Policy) preventing authentication pages from being rendered inside an iframe.

Relevant Configuration

shopify.server.ts

import { shopifyApp } from "@shopify/shopify-app-remix/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";

const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecretKey: process.env.SHOPIFY_API_SECRET,
  apiVersion: ApiVersion.January25,
  scopes: process.env.SCOPES?.split(","),
  appUrl: process.env.SHOPIFY_APP_URL || "",
  authPathPrefix: "/auth",
  sessionStorage: new PrismaSessionStorage(prisma),
  future: {
    unstable_newEmbeddedAuthStrategy: true,
    expiringOfflineAccessTokens: true,
  },
});

Questions

  1. With unstable_newEmbeddedAuthStrategy: true enabled, we expected the Remix package to either:

    • Handle authentication inline, or

    • Trigger a top-level redirect using App Bridge to escape the iframe.

    Why does authenticate.admin(request) appear to perform a standard redirect to /auth that ultimately leads to the iframe attempting to load accounts.shopify.com?

  2. What is the recommended approach in @shopify/shopify-app-remix v4 for handling:

    • Fresh installs (no stored session)

    • Scope updates requiring reauthorization

    without requiring merchants to manually open the app in a top-level browser tab?

  3. Is there any additional configuration required for the new embedded auth strategy to automatically perform top-level OAuth redirects or use App Bridge’s authentication flow?

Any guidance or examples from apps using the new embedded auth strategy would be greatly appreciated.

The “refused to connect” error here is expected Shopify security behaviour, not a bug in your setup. accounts.shopify.com sets X-Frame-Options: DENY, so any redirect that lands there inside the admin iframe will fail. The fix is making sure the redirect escapes the iframe before it reaches the auth domain, and that’s exactly what unstable_newEmbeddedAuthStrategy is supposed to handle, but it has a specific requirement that’s easy to miss.

Why authenticate.admin still does a “dumb” redirect

When unstable_newEmbeddedAuthStrategy: true is set, the library switches from Auth Code Flow to Token Exchange. Token Exchange works great once a valid session token exists, it can refresh inline without a redirect. But in the two cases you describe (empty session table, scope mismatch), there is no valid session token to exchange. The library falls back to a standard OAuth redirect, and that redirect hits the iframe CSP wall.

The mechanism for escaping the iframe in that fallback situation is the X-Shopify-API-Request-Failure-Reauthorize-Url response header. App Bridge in the parent frame watches for that header on iframe responses and top-frame-navigates the parent window to the install URL. The key is that your loader must return that 401 response rather than throw a Remix redirect() to your own /auth route.

What to check / change

  1. Don’t throw a Remix redirect from your loader when you detect a missing or stale session. Let authenticate.admin(request) handle it. If it throws a Response (not a redirect), App Bridge catches the header. If it throws a Remix redirect to your /auth, the iframe navigates there directly and you’re back to the CSP error.

  2. The scopes field in shopifyApp({}) is driving your scope comparison. You have scopes: process.env.SCOPES?.split(","), if that env var is out of sync with your deployed shopify.app.toml, the library’s internal scope check will behave inconsistently. The TOML is what Shopify Partner enforces; the env var is what your app thinks it needs. Treat the TOML as source of truth, not the env var.

  3. For the scope-mismatch case specifically, authenticate.admin with the new embedded auth strategy does not automatically re-check granted scopes on every request the way the old Auth Code Flow strategy did. You need an explicit guard. Call context.scopes.request(requiredScopesArray) in your loader when you detect drift; this throws a Response with the reauthorise header, which App Bridge handles correctly. A hand-rolled redirect() to an interstitial page will not work reliably inside the iframe.

  4. Fresh install / empty DB is a slightly different case: there is no offline session row, so the library can’t do token exchange at all. Ensure your /auth route (and authenticate.webhook) can handle the full OAuth code flow as a fallback path, because that path still runs even with the new strategy enabled. The new strategy replaces the inline refresh, not the initial install flow.

The short version: the library’s escape hatch from the iframe depends on App Bridge receiving the Reauthorize-Url header, not on a redirect. Any code path that throws a Remix-level redirect to /auth instead of letting the library throw a Response will bypass that mechanism and hit the CSP block.

software-clever is right that accounts.shopify.com can never render inside the admin iframe. The part worth adding is why your setup reaches that redirect at all when you already have unstable_newEmbeddedAuthStrategy turned on.

With the new strategy auth is meant to happen through token exchange, no redirect. The app reads the session token from App Bridge and swaps it for an access token silently. You only get bounced to a full page OAuth redirect when that silent path cannot run, and the two scenarios you described are exactly the two that break it. An empty Session table by itself is fine for token exchange, but if App Bridge has not loaded yet there is no session token to exchange, so the library falls back to a redirect and that redirect tries to paint inside the frame. A scope change is a different animal. That genuinely needs the merchant to re-consent, and consent has to happen at the top level, never in the iframe.

So the fix splits in two. For the fresh session case, make sure App Bridge is initialised before any auth code runs so the session token actually exists for the exchange. For the scope change case, catch it and send the merchant out with a top level redirect using App Bridge with target _top, rather than letting a server side redirect land in the frame.

Are you hitting the error on the very first load of a fresh install, or only after you push a scopes change to a store that was already installed? Those two point at the two different fixes.