Graphql 403 GraphQL Client: Forbidden when switching local app to public status to test billing

I am trying to test the billing feature in my app and I switched the distribution of the app to public in order to do this as suggested in this previous post and other posts similar to that one:

After setting the app to public the graphql admin permissions seems to immediately break in my local app which is being tunnelled through Cloudflare with the error of:

{

  "errors": {

    "networkStatusCode": 403,

    "message": "GraphQL Client: Forbidden",

    "response": {}

  }

} 

I also enabled Managed App Pricing as described here: Managed App Pricing

At this point I have tried a many things to try to fix the problem and none of them have worked:

  1. Uninstall and reinstall the app.
  2. Hardcode the dynamic local Cloudflare tunnel URL in the dev dashboard so the app url and redirect URLs match up.
  3. Install the app on a new dev store (Basic, Grow, etc.).
  4. Refreshed my Shopify session token by deleting my existing token and getting an updated one on app initialization.
  5. Ensuring my access scopes in my .toml file matches the access scopes on the dev dashboard.
  6. Cleared cookies and browser storage.
  7. Created new apps with the same and different .toml configurations using the Shopify CLI.

The one constant is as soon as the local app is switched to a public app in the Distribution panel the 403 errors occur.

Is there a way to get around this issue or it is better to not enable public distribution for a local dev app and create another app only for production where that app has public distribution enabled? This scenario would make it less than ideal to test billing in a dev environment as the billing workflow would have to be tested in a production-like environment.

Overall the weird thing about this workflow is Shopify seems to inform devs Managed App Pricing is easier ( Billing ) rather than using the Billing API but once Managed App Pricing is enabled after converting the distribution of the app to public the app breaks with 403 errors making the app unusable.

Would appreciate any help on this issue!

After doing a lot of persistence and experimentation, it looks like the issue is related to Shopify’s new requirement for of using expiring tokens mandated on April 1, 2026 for public apps. The announcement was made:

More resources for migrating from non-expiring to expiring tokens can be viewed here:

It’s funny this only occurs once the distribution of your app is made public and there is no documentation mentioning this or any helpful error messages providing useful debugging information. Very typical of Shopify to make breaking changes.

Steps to fix the issue in your code

Using Shopify’s existing session store code:

  1. Update the @shopify/shopify-api, @shopify/shopify-app-react-router or whatever Shopify based package you are using to at least 1.2.0 (will likely change in the future but as of April 9, 2026 this is the most current version).

  2. If you are using one of Shopify’s session packages such as the MySQL adapter:
    https://github.com/Shopify/shopify-app-js/tree/main/packages/apps/session-storage/shopify-app-session-storage-mysql#readme
    or the SQLite adapter: https://github.com/Shopify/shopify-app-js/blob/main/packages/apps/session-storage/shopify-app-session-storage-sqlite/MIGRATION_TO_EXPIRING_TOKENS.md ensure you are on the newest version of those packages and already have expiring refresh token code implemented.

  3. After this you may have to perform a database migration to add the refreshToken and refreshTokenExpires columns to your existing Shopify sessions table. You may also need to uninstall and reinstall the app to get new tokens in your db.

  4. Set the expiringOfflineAccessTokens: true flag in the shopifyApp constructor call in shopify.server.ts:

    shopifyApp({
      apiKey: process.env.SHOPIFY_API_KEY,
      apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
      apiVersion: (process.env.SHOPIFY_API_VERSION as ApiVersion) || ApiVersion.October25,
      scopes: process.env.SCOPES?.split(","),
      appUrl: process.env.SHOPIFY_APP_URL || "",
      authPathPrefix: "/auth",
      sessionStorage,
      distribution: AppDistribution.AppStore,
      future: {
        expiringOfflineAccessTokens: true,
      }
    });

Using your own custom session store:

The following instructions are for Cloudflare D1 database which is a SQLite based db

  1. Modify your Shopify session table to add the columns refreshToken and refreshTokenExpires. In my case I just dropped and recreated the table:
CREATE TABLE IF NOT EXISTS shopify_sessions (
  id TEXT PRIMARY KEY,
  shop TEXT NOT NULL,
  state TEXT,
  isOnline INTEGER,
  scope TEXT,
  accessToken TEXT,
  expires INTEGER,
  refreshToken TEXT,
  refreshTokenExpires INTEGER,
  onlineAccessInfo TEXT
)
  1. Modify your custom session store that implements the SessionStorage interface from: https://github.com/Shopify/shopify-app-js/tree/main/packages/apps/session-storage/shopify-app-session-storage#readme
import { Session } from "@shopify/shopify-api";
import { SessionStorage } from "@shopify/shopify-app-session-storage";

export class D1SessionStorage implements SessionStorage {
  async storeSession(session: Session): Promise<boolean> {
    const db = globalThis.shopifyDb;
    if (!db) {
      console.error("D1 database not initialized");
      return false;
    }

    try {
      await db
        .prepare(
          `
        INSERT OR REPLACE INTO shopify_sessions
        (id, shop, state, isOnline, scope, accessToken, expires, refreshToken, refreshTokenExpires, onlineAccessInfo)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      `
        )
        .bind(
          session.id || null,
          session.shop || null,
          session.state || null,
          session.isOnline ? 1 : 0,
          session.scope || null,
          session.accessToken || null,
          session.expires ? session.expires.getTime() : null,
          session.refreshToken || null,
          session.refreshTokenExpires ? session.refreshTokenExpires.getTime() : null,
          session.onlineAccessInfo ? JSON.stringify(session.onlineAccessInfo) : null
        )
        .run();
      return true;
    } catch (error) {
      console.error("Failed to store session:", error);
      return false;
    }
  }

  async loadSession(id: string): Promise<Session | undefined> {
    const db = globalThis.shopifyDb;
    if (!db) {
      console.error("[D1SessionStorage.loadSession] D1 database not initialized");
      return undefined;
    }

    try {
      const result = await db
        .prepare(
          `
        SELECT * FROM shopify_sessions WHERE id = ?
      `
        )
        .bind(id || null)
        .first();

      if (!result) return undefined;

      const session = new Session({
        id: result.id as string,
        shop: result.shop as string,
        state: result.state as string,
        isOnline: Boolean(result.isOnline)
      });

      session.scope = result.scope as string;
      session.accessToken = result.accessToken as string;

      if (result.expires) {
        session.expires = new Date(result.expires as number);
      }

      if (result.refreshTokenExpires) {
        session.refreshTokenExpires = new Date(result.refreshTokenExpires as number);
      }

      if (result.onlineAccessInfo) {
        session.onlineAccessInfo = JSON.parse(result.onlineAccessInfo as string);
      }

      return session;
    } catch (error) {
      console.error("[D1SessionStorage.loadSession] Failed to load session:", error);
      return undefined;
    }
  }

  async deleteSession(id: string): Promise<boolean> {
    const db = globalThis.shopifyDb;
    if (!db) {
      console.error("D1 database not initialized");
      return false;
    }

    try {
      await db
        .prepare(
          `
        DELETE FROM shopify_sessions WHERE id = ?
      `
        )
        .bind(id || null)
        .run();
      return true;
    } catch (error) {
      console.error("Failed to delete session:", error);
      return false;
    }
  }

  async deleteSessions(ids: string[]): Promise<boolean> {
    const db = globalThis.shopifyDb;
    if (!db) {
      console.error("D1 database not initialized");
      return false;
    }

    try {
      for (const id of ids) {
        await this.deleteSession(id);
      }
      return true;
    } catch (error) {
      console.error("Failed to delete sessions:", error);
      return false;
    }
  }

  async findSessionsByShop(shop: string): Promise<Session[]> {
    const db = globalThis.shopifyDb;
    if (!db) {
      console.error("D1 database not initialized");
      return [];
    }

    try {
      const results = await db
        .prepare(
          `
        SELECT * FROM shopify_sessions WHERE shop = ?
      `
        )
        .bind(shop || null)
        .all();

      return results.results.map((result: Record<string, unknown>) => {
        const session = new Session({
          id: result.id as string,
          shop: result.shop as string,
          state: result.state as string,
          isOnline: Boolean(result.isOnline)
        });

        session.scope = result.scope as string;
        session.accessToken = result.accessToken as string;

        if (result.expires) {
          session.expires = new Date(result.expires as number);
        }

        if (result.refreshTokenExpires) {
          session.refreshTokenExpires = new Date(result.refreshTokenExpires as number);
        }

        if (result.onlineAccessInfo) {
          session.onlineAccessInfo = JSON.parse(result.onlineAccessInfo as string);
        }

        return session;
      });
    } catch (error) {
      console.error("Failed to find sessions by shop:", error);
      return [];
    }
  }
}


  1. Set the expiringOfflineAccessTokens: true flag in the shopifyApp constructor call in shopify.server.ts:
    shopifyApp({
      apiKey: process.env.SHOPIFY_API_KEY,
      apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
      apiVersion: (process.env.SHOPIFY_API_VERSION as ApiVersion) || ApiVersion.October25,
      scopes: process.env.SCOPES?.split(","),
      appUrl: process.env.SHOPIFY_APP_URL || "",
      authPathPrefix: "/auth",
      sessionStorage,
      distribution: AppDistribution.AppStore,
      future: {
        expiringOfflineAccessTokens: true,
      }
    });

Take note the additions of refreshToken and refreshTokenExpires in functions storeSession, loadSession and findSessionsByShop were added.

I also tried it and encountered the same issue, waiting for a result.

I posted a response to my question above. Hopefully it can help with your issue too!