I recently finished migrating two Shopify apps to expiring offline tokens:
- one built with React Router
- one built with Remix
After the migration, I kept a few small server-side helpers that were useful in both apps.
These helpers cover:
- migrating one shop from a legacy offline token
- manually refreshing one shop
- running a scheduled refresh job
- optionally forwarding the refreshed token to another backend service if you have background jobs outside the Shopify app
Hope this helps.
This document assumes that you have already completed the migration and that the database now includes the fields related to expiring tokens. Note that this is my personal way of using it and may not be entirely correct, but I have verified that it works for me.
DB Index
I recommend an index for scheduled refresh scans(if you need it):
CREATE INDEX "Session_token_refresh_due_idx"
ON "Session" ("expires", "refreshTokenExpires", "shop")
WHERE "isOnline" = false AND "expires" IS NOT NULL AND "refreshToken" IS NOT NULL;
app/shopifyToken.server.ts
This is a small Shopify API instance only for token migration and refresh.(No additional environment variables were added here; these are all variables required for the service to start in the template.)
import '@shopify/shopify-app-remix/adapters/node';
import { shopifyApi as createShopifyApi } from '@shopify/shopify-api';
import { ApiVersion } from '@shopify/shopify-app-remix/server';
function createTokenApi() {
const appUrl = process.env.SHOPIFY_APP_URL;
const apiKey = process.env.SHOPIFY_API_KEY;
const apiSecretKey = process.env.SHOPIFY_API_SECRET;
const scopes = process.env.SCOPES?.split(',') ?? [];
if (!appUrl || !apiKey || !apiSecretKey) {
throw new Error(
'SHOPIFY_APP_URL, SHOPIFY_API_KEY, or SHOPIFY_API_SECRET is not configured'
);
}
const parsedAppUrl = new URL(appUrl);
return createShopifyApi({
apiKey,
apiSecretKey,
apiVersion: ApiVersion.January26,
hostName: parsedAppUrl.hostname,
hostScheme: parsedAppUrl.protocol.replace(':', '') as 'http' | 'https',
isEmbeddedApp: true,
scopes,
});
}
export const shopifyTokenApi = createTokenApi();
app/services/shopifyTokenRefresh.server.ts
This file handles:
- single-shop migration
- single-shop refresh
- batch migration
- scheduled refresh
I removed project-specific logic and kept one extension point:
handleOfflineTokenUpdated
If you have another backend service, put your sync logic there.
import { HttpResponseError } from '@shopify/shopify-api';
import prisma from '~/db.server';
import { shopifyTokenApi } from '~/shopifyToken.server';
// Token refresh interval and window
const TOKEN_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
const TOKEN_REFRESH_WINDOW_MS = 20 * 60 * 1000;
const EXPIRED_REFRESH_TOKEN_DATE = new Date(0);
type TokenRefreshGlobalState = {
intervalId?: ReturnType<typeof setInterval>;
isRunning: boolean;
};
type MigrationResult = {
migrated: number;
skipped: number;
failed: Array<{
shop: string;
reason: string;
}>;
};
type TokenOperationResult = {
ok: boolean;
shop: string;
status: 'migrated' | 'refreshed' | 'skipped' | 'failed';
message?: string;
};
type OfflineSessionRecord = {
accessToken: string;
expires: Date | null;
id: string;
refreshToken: string | null;
refreshTokenExpires: Date | null;
scope: string | null;
shop: string;
};
type RefreshedOfflineSession = {
accessToken?: string;
expires?: Date;
refreshToken?: string;
refreshTokenExpires?: Date;
scope?: string;
shop: string;
};
const globalTokenRefreshState = globalThis as typeof globalThis & {
__shopifyTokenRefreshService?: TokenRefreshGlobalState;
};
const getTokenRefreshState = () => {
globalTokenRefreshState.__shopifyTokenRefreshService ??= {
isRunning: false,
};
return globalTokenRefreshState.__shopifyTokenRefreshService;
};
const getErrorMessage = (error: unknown) => {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return 'Unknown error';
};
const isRejectedRefreshTokenError = (error: unknown) =>
error instanceof HttpResponseError && error.response?.code === 401;
const handleOfflineTokenUpdated = async (
_shop: string,
_accessToken: string
) => {
/**
* Optional extension point.
*
* Example:
* - notify another backend service
* - update a token cache
* - publish a queue event
*
* Keep this server-only.
* Do not log raw token values.
*/
};
const markRefreshTokenExpired = async (sessionId: string) => {
await prisma.session.update({
where: {
id: sessionId,
},
data: {
refreshTokenExpires: EXPIRED_REFRESH_TOKEN_DATE,
},
});
};
const updateSessionTokenFields = async (
sessionId: string,
current: Pick<
OfflineSessionRecord,
'accessToken' | 'expires' | 'refreshToken' | 'refreshTokenExpires' | 'scope'
>,
refreshed: RefreshedOfflineSession
) => {
await prisma.session.update({
where: {
id: sessionId,
},
data: {
accessToken: refreshed.accessToken ?? current.accessToken,
expires: refreshed.expires ?? current.expires,
refreshToken: refreshed.refreshToken ?? current.refreshToken,
refreshTokenExpires:
refreshed.refreshTokenExpires ?? current.refreshTokenExpires,
scope: refreshed.scope ?? current.scope,
},
});
};
const refreshOfflineSession = async (
session: OfflineSessionRecord
): Promise<TokenOperationResult> => {
try {
if (!session.refreshToken) {
throw new Error('Session has no refresh token');
}
if (!session.refreshTokenExpires) {
throw new Error('Session has no refresh token expires');
}
if (session.refreshTokenExpires.getTime() <= Date.now()) {
throw new Error('Session refresh token has expired');
}
const { session: refreshedSession } =
await shopifyTokenApi.auth.refreshToken({
shop: session.shop,
refreshToken: session.refreshToken,
});
if (!refreshedSession.accessToken) {
throw new Error('Refreshed Shopify session has no access token');
}
await updateSessionTokenFields(session.id, session, refreshedSession);
await handleOfflineTokenUpdated(
refreshedSession.shop,
refreshedSession.accessToken
);
console.log(`[Shopify token refresh] refreshed shop ${session.shop}`);
return {
ok: true,
shop: session.shop,
status: 'refreshed',
};
} catch (error) {
if (isRejectedRefreshTokenError(error)) {
await markRefreshTokenExpired(session.id);
}
console.log(
`[Shopify token refresh] failed for shop ${session.shop}: ${getErrorMessage(
error
)}`
);
return {
ok: false,
shop: session.shop,
status: 'failed',
message: getErrorMessage(error),
};
}
};
export const refreshOfflineSessionByShop = async (
shop: string
): Promise<TokenOperationResult> => {
const session = await prisma.session.findFirst({
where: {
isOnline: false,
shop,
},
select: {
accessToken: true,
expires: true,
id: true,
refreshToken: true,
refreshTokenExpires: true,
scope: true,
shop: true,
},
});
if (!session) {
return {
ok: false,
shop,
status: 'failed',
message: 'Offline session not found',
};
}
return refreshOfflineSession(session);
};
export const migrateLegacyOfflineSessionByShop = async (
shop: string
): Promise<TokenOperationResult> => {
const legacySession = await prisma.session.findFirst({
where: {
isOnline: false,
shop,
},
select: {
accessToken: true,
expires: true,
id: true,
refreshToken: true,
refreshTokenExpires: true,
scope: true,
shop: true,
},
});
if (!legacySession) {
return {
ok: false,
shop,
status: 'failed',
message: 'Offline session not found',
};
}
if (legacySession.refreshToken || legacySession.refreshTokenExpires) {
return {
ok: true,
shop,
status: 'skipped',
message: 'Offline session already has refresh token data',
};
}
try {
const { session } = await shopifyTokenApi.auth.migrateToExpiringToken({
shop: legacySession.shop,
nonExpiringOfflineAccessToken: legacySession.accessToken,
});
if (!session.refreshToken || !session.refreshTokenExpires) {
return {
ok: false,
shop,
status: 'failed',
message: 'Migrated session is missing refresh token data',
};
}
if (!session.accessToken) {
throw new Error('Migrated Shopify session has no access token');
}
await updateSessionTokenFields(legacySession.id, legacySession, session);
await handleOfflineTokenUpdated(session.shop, session.accessToken);
console.log(
`[Shopify token migration] migrated shop ${legacySession.shop}`
);
return {
ok: true,
shop,
status: 'migrated',
};
} catch (error) {
console.log(
`[Shopify token migration] failed for shop ${legacySession.shop}: ${getErrorMessage(
error
)}`
);
return {
ok: false,
shop,
status: 'failed',
message: getErrorMessage(error),
};
}
};
const refreshExpiringShopifyTokens = async () => {
console.log('[Shopify token refresh] start refresh');
const now = new Date();
const refreshBefore = new Date(now.getTime() + TOKEN_REFRESH_WINDOW_MS);
const sessions = await prisma.session.findMany({
where: {
isOnline: false,
accessToken: {
not: '',
},
shop: {
not: '',
},
expires: {
lte: refreshBefore,
},
refreshToken: {
not: null,
},
refreshTokenExpires: {
gt: now,
},
},
select: {
accessToken: true,
expires: true,
id: true,
refreshToken: true,
refreshTokenExpires: true,
scope: true,
shop: true,
},
});
if (sessions.length === 0) {
console.log('[Shopify token refresh] no session need to refresh');
return;
}
console.log(
`[Shopify token refresh] found ${sessions.length} sessions to refresh`
);
for (const session of sessions) {
await refreshOfflineSession(session);
}
};
export const runShopifyTokenRefreshOnce = async () => {
const state = getTokenRefreshState();
if (state.isRunning) {
console.log('[Shopify token refresh] skipped because a run is active');
return;
}
state.isRunning = true;
try {
await refreshExpiringShopifyTokens();
} finally {
state.isRunning = false;
}
};
export const startShopifyTokenRefreshService = () => {
const state = getTokenRefreshState();
if (state.intervalId) {
return;
}
state.intervalId = setInterval(() => {
runShopifyTokenRefreshOnce().catch((error: unknown) => {
console.log(
`[Shopify token refresh] run failed: ${getErrorMessage(error)}`
);
});
}, TOKEN_REFRESH_INTERVAL_MS);
runShopifyTokenRefreshOnce().catch((error: unknown) => {
console.log(
`[Shopify token refresh] initial run failed: ${getErrorMessage(error)}`
);
});
};
export const migrateLegacyOfflineSessions =
async (): Promise<MigrationResult> => {
const result: MigrationResult = {
migrated: 0,
skipped: 0,
failed: [],
};
const legacySessions = await prisma.session.findMany({
where: {
isOnline: false,
accessToken: {
not: '',
},
shop: {
not: '',
},
refreshToken: null,
refreshTokenExpires: null,
},
select: {
accessToken: true,
shop: true,
},
});
for (const legacySession of legacySessions) {
const migration = await migrateLegacyOfflineSessionByShop(
legacySession.shop
);
if (migration.status === 'migrated') {
result.migrated += 1;
continue;
}
if (migration.status === 'skipped') {
result.skipped += 1;
continue;
}
if (!migration.ok) {
result.failed.push({
shop: legacySession.shop,
reason: migration.message ?? 'Unknown error',
});
}
}
return result;
};
Optional internal routes
I also kept three internal helpers:
- batch migration
- single-shop migration
- single-shop refresh
Batch migration:
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { migrateLegacyOfflineSessions } from '~/services/shopifyTokenRefresh.server';
import { requireTokenMigrationSecret } from '~/utils/internalRouteAuth.server';
export const loader = async ({ request }: LoaderFunctionArgs) => {
requireTokenMigrationSecret(request);
return json({
ok: true,
message: 'Use POST to run Shopify token migration.',
});
};
export const action = async ({ request }: ActionFunctionArgs) => {
requireTokenMigrationSecret(request);
const result = await migrateLegacyOfflineSessions();
return json({
ok: true,
...result,
});
};
Single-shop migration:
import type { ActionFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { migrateLegacyOfflineSessionByShop } from '~/services/shopifyTokenRefresh.server';
import { requireTokenMigrationSecret } from '~/utils/internalRouteAuth.server';
const getShopFromRequest = async (request: Request) => {
const body: unknown = await request.json();
if (!body || typeof body !== 'object') {
return '';
}
const shop = (body as { shop?: unknown }).shop;
return typeof shop === 'string' ? shop.trim() : '';
};
export const action = async ({ request }: ActionFunctionArgs) => {
requireTokenMigrationSecret(request);
const shop = await getShopFromRequest(request);
if (!shop) {
return json({ ok: false, error: 'shop is required' }, { status: 400 });
}
const result = await migrateLegacyOfflineSessionByShop(shop);
return json(result, {
status: result.ok ? 200 : 400,
});
};
Single-shop refresh:
import type { ActionFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { refreshOfflineSessionByShop } from '~/services/shopifyTokenRefresh.server';
import { requireTokenMigrationSecret } from '~/utils/internalRouteAuth.server';
const getShopFromRequest = async (request: Request) => {
const body: unknown = await request.json();
if (!body || typeof body !== 'object') {
return '';
}
const shop = (body as { shop?: unknown }).shop;
return typeof shop === 'string' ? shop.trim() : '';
};
export const action = async ({ request }: ActionFunctionArgs) => {
requireTokenMigrationSecret(request);
const shop = await getShopFromRequest(request);
if (!shop) {
return json({ ok: false, error: 'shop is required' }, { status: 400 });
}
const result = await refreshOfflineSessionByShop(shop);
return json(result, {
status: result.ok ? 200 : 400,
});
};
Start the scheduled refresh service
For a long-running Node process:
import { startShopifyTokenRefreshService } from './services/shopifyTokenRefresh.server';
startShopifyTokenRefreshService();
I usually run this from the server entry.
Final note
This setup worked well for both of my migrated apps:
- React Router
- Remix
The main useful pieces for me were:
- migrate one shop
- refresh one shop manually
- run a scheduled refresh job
- push the new token to another backend if needed
Hope this helps.