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:
-
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).
-
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.
-
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.
-
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
- 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
)
- 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 [];
}
}
}
- 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.