Insights

ArrowDown

The eCommerce landscape is always shifting, demanding fresh strategies and continuous optimization. Our team brings you expert insights, practical tips, and the latest trends to help your business grow and keep you ahead of the curve.

How to Migrate to Shopify's New Expiring Access Tokens

How to Migrate to Shopify’s New Expiring Access Tokens

If you’ve built or maintained a Shopify app, you know the offline access token as the thing you grab once during install and then never think about again. It sits in your database, works forever, and just quietly authenticates every background job, webhook handler, and cron task your app runs — until you uninstall or the merchant does.

That’s changing. Shopify is moving offline access tokens from non-expiring to expiring, backed by a refresh token, the same pattern used by nearly every other OAuth2 API on the planet. If your app talks to the Shopify Admin API outside of a live user session, this affects you directly, and there’s a hard deadline attached.

Why a Token That Expires Is Actually the Better Design

It sounds like a downgrade — one more thing to manage instead of “set it and forget it.” In practice, it’s a meaningful security upgrade, and it costs your merchants nothing.

  • Smaller blast radius on a leak. A non-expiring token that ends up in a log file, a stale backup, or a compromised server is valid forever, for whoever has it. An expiring token is a ticking clock — even a leaked token becomes useless within an hour.
  • Forced rotation, by design. Every refresh issues a brand-new access token and a brand-new refresh token. You’re not relying on your team to remember to rotate a long-lived secret manually; the protocol rotates it for you every time your app uses it.
  • No end-user friction. This is still an offline token — there’s no merchant re-authentication prompt, no interruption to your app’s UX. The only thing that changes is what your server does behind the scenes.
  • Standard OAuth2 hygiene. Shopify is simply aligning offline tokens with the access-token/refresh-token pattern that Google, Microsoft, and virtually every modern API already use. If your team has integrated with any of those, this will feel familiar.

How the New Tokens Actually Work

There are now two flavors of offline token:

Legacy (non-expiring)New (expiring)
Access token lifetimeForever (until uninstall/secret revocation)1 hour (expires_in: 3600)
Refresh token lifetimeN/A90 days (refresh_token_expires_in: 7776000)
RotationNoneNew access + refresh token pair on every refresh

To request an expiring token, add expiring=1 to your existing OAuth flow:

  • Token exchange (the recommended flow if your app uses App Bridge session tokens): POST https://{shop}.myshopify.com/admin/oauth/access_token with client_id, client_secret, grant_type: urn:ietf:params:oauth:grant-type:token-exchange, subject_token, subject_token_type, requested_token_type: urn:shopify:params:oauth:token-type:offline-access-token, and expiring=1.
  • Authorization code grant (classic OAuth install flow): same endpoint, swap subject_token for the code from the redirect, and add expiring=1.

Once your access token expires, you refresh it against the same endpoint with grant_type: refresh_token and your current refresh_token. The response hands you a fresh access token and a fresh refresh token — the one you just used is immediately invalidated.

One detail worth building into your retry logic: Shopify replays the exact same response for up to an hour after a successful refresh, so if your first request times out on the network but actually succeeded, retrying is safe. Treat timeouts, 5xx, and 429 responses as transient and safe to retry — but a 401 with invalid_request means the token relationship is broken and you need to re-acquire access via token exchange from scratch.

The Deadline

This isn’t optional forever:

  • Public apps created after April 1, 2026 must use expiring tokens from day one.
  • Public apps created before that date have until January 1, 2027 to migrate — after which, requests using the old non-expiring flow will start getting rejected.
  • Custom apps and merchant-created apps are exempt from the requirement, though there’s no downside to adopting the pattern anyway.

If you maintain a public app, this is a real migration project with a real deadline, not a “someday” backlog item.

A 403 We Ran Into Ourselves: GraphQL Client: Forbidden on a Brand-New App

We hit this firsthand while testing this migration: we created a new test app in the Partner Dashboard — well after April 1, 2026 — reusing the codebase of an app we’d originally built before that cutoff. Every Admin GraphQL call on the new app came back with a plain, generic 403, which the SDK surfaces as GraphQL Client: Forbidden, with no further detail in the response body.

The cause was exactly the enforcement rule above: our codebase still requested the legacy, non-expiring offline token flow — which was fine for the original app, since it predates April 1, 2026. But the new app we’d just created falls squarely under the “created after April 1, 2026” rule, which requires expiring tokens from day one. Shopify enforces that immediately for new apps — it doesn’t wait for the January 1, 2027 grace period, since that date only applies to apps that already existed before the cutoff. Our old, non-expiring OAuth flow was rejected outright, and it showed up as a 403 on the GraphQL client rather than a clear error at the OAuth step.

The fix was exactly the migration step below: enable expiringOfflineAccessTokens: true (or add expiring=1 if you’re calling the token exchange/OAuth endpoints directly) so the app requests a compliant expiring token from install. Once we did that, the same codebase authenticated cleanly against the new app.

If you landed here searching for that exact error — a plain GraphQL Client: Forbidden / 403 on Admin GraphQL calls from an app you just created — check your app’s creation date against April 1, 2026 first. It’s an easy thing to miss if you’re reusing an older codebase to spin up new test apps or new client IDs, exactly like we did.

How to Migrate an Existing App

  1. Extend your session storage. Add expires_at, refresh_token, and refresh_token_expires_at alongside the access token you already store per shop.
  2. Add refresh-before-use logic. Before any background job, webhook handler, or scheduled task calls the Admin API, check if the access token is expired (or close to it) and refresh proactively rather than reacting to a 401 mid-request.
  3. Switch new installs to expiring tokens. Add expiring=1 to your install flow so every new merchant lands on the new pattern immediately.
  4. Migrate existing installs. For merchants who installed before this change, token-exchange their old non-expiring token as the subject_token to receive an expiring pair. Be careful here: this revokes the old non-expiring token immediately, and it’s irreversible. Don’t run this migration until your refresh logic is live and tested — there’s no falling back once you’ve flipped a shop over.

In Code: Doing This With the Node SDK

If your app runs on @shopify/shopify-api (which shopify-app-express and shopify-app-remix both wrap), you don’t need to hand-roll the HTTP calls above — the four migration steps map onto three SDK methods.

New installs — request an expiring token during token exchange:

import { RequestedTokenType } from "@shopify/shopify-api";

const { session } = await shopify.auth.tokenExchange({
  sessionToken, // from the Authorization header or `id_token` param
  shop,
  requestedTokenType: RequestedTokenType.OfflineAccessToken,
  expiring: true,
});

await sessionStorage.storeSession(session);

Existing installs — migrate the old non-expiring token, once:

const { session } = await shopify.auth.migrateToExpiringToken({
  shop,
  nonExpiringOfflineAccessToken: existingToken,
});

await sessionStorage.storeSession(session);

This is the SDK call that revokes existingToken immediately — don’t run it for a shop until the refresh logic below is deployed and tested.

Before any background call — check expiry and refresh proactively:

async function getFreshOfflineSession(shop: string) {
  const session = await sessionStorage.loadOfflineSession(shop);

  const expiringSoon =
    session.expires &&
    session.expires.getTime() - Date.now() < 5 * 60 * 1000; // 5 min buffer

  if (expiringSoon && session.refreshToken) {
    const { session: refreshed } = await shopify.auth.refreshToken({
      shop,
      refreshToken: session.refreshToken,
    });
    await sessionStorage.storeSession(refreshed);
    return refreshed;
  }

  return session;
}

Call getFreshOfflineSession wherever your webhook handlers and background jobs currently load a session, and the 1-hour access token lifetime becomes an implementation detail the rest of your codebase never has to think about.

If You’re on shopify-app-react-router

If you scaffolded your app with shopify app init recently, or migrated off shopify-app-remix, you’re on @shopify/shopify-app-react-router — the framework package that wraps everything above. It uses token exchange as its default auth strategy, so there’s no classic OAuth redirect flow to maintain. You generally don’t call shopify.auth.tokenExchange or shopify.auth.refreshToken yourself either — instead, you get a valid session through one of three entry points depending on where the code runs.

One thing to know precisely: expiring tokens aren’t on by default here yet. Support landed behind a future flag called expiringOfflineAccessTokens — introduced in @shopify/shopify-app-remix@4.1.0 and, for the React Router package, in @shopify/shopify-app-react-router@1.1.0. As of the latest releases at the time of writing (shopify-app-remix@4.2.1, shopify-app-react-router@1.2.1), it’s still opt-in and defaults to false — the library hasn’t flipped it on for you yet, so on any current version you need to turn it on explicitly:

// app/shopify.server.ts
export const shopify = shopifyApp({
  // ...your existing api, appUrl, etc. config
  future: {
    expiringOfflineAccessTokens: true,
  },
});

With that flag on, authenticate.admin, authenticate.webhook, and unauthenticated.admin all start transparently checking for expiry and refreshing offline sessions for you — which is what the three examples below rely on. Given the migration deadlines above, don’t wait for a future version to make this the default; flip it now and test the flow before your app is forced into it.

In a UI route’s loader or action:

import type { LoaderFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { admin, session } = await authenticate.admin(request);

  const response = await admin.graphql(`#graphql
    query {
      shop { name }
    }
  `);

  return response.json();
};

authenticate.admin checks session storage for a valid session first; if it’s missing or expired, it performs token exchange (or refresh) for you before handing back admin and session.

In a webhook handler:

import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";

export const action = async ({ request }: ActionFunctionArgs) => {
  const { topic, admin, session, payload } = await authenticate.webhook(request);

  if (!session) {
    // The app may have been uninstalled — no offline session to use.
    throw new Response();
  }

  if (topic === "PRODUCTS_UPDATE") {
    await admin.graphql(`#graphql
      mutation touchProduct($id: ID!, $time: String!) {
        metafieldsSet(metafields: [{
          ownerId: $id, namespace: "my-app", key: "webhook_at", value: $time, type: "string"
        }]) { metafields { key } }
      }
    `, { variables: { id: payload.admin_graphql_api_id, time: new Date().toISOString() } });
  }

  return new Response();
};

In a background job or worker — outside any request context (a queue consumer, a cron task), there’s no live session token to re-run token exchange with, so the package falls back to loading (and, if needed, refreshing) whatever offline session is already in your storage adapter:

import { unauthenticated } from "../shopify.server";

export async function syncInventory(shop: string) {
  const { admin } = await unauthenticated.admin(shop);
  await admin.graphql(`#graphql
    query { shop { name } }
  `);
}

The detail that trips people up here: only pass the shop domain into your job queue payload, never a session or token. Call unauthenticated.admin(shop) fresh inside each job execution instead of reusing a cached client from when the job was enqueued — that’s what guarantees you’re working with the current, possibly just-refreshed, access token rather than one that expired while the job was sitting in the queue.

One Gotcha Worth Planning For

The 90-day refresh token lifetime is a hard ceiling, not a soft warning. If a merchant’s shop goes quiet — no jobs running, no webhooks firing — for more than 90 days, your refresh token expires right along with everything else. At that point, a refresh call won’t work; you need to re-acquire access through token exchange again, effectively treating it as a fresh install. If your app has any low-traffic or dormant shops, build a check for this so you’re not silently locked out of an account you thought was still connected.

Don’t Let This Slip Through the Cracks

This is exactly the kind of platform change that’s easy to miss until it breaks something in production — a deadline announced in a changelog, not a page anyone re-reads. If you’re not sure whether your app’s token handling is ready for this, or you’d rather have someone tracking Shopify’s platform changes for you instead of discovering them the hard way, that’s precisely what a Dev Retainer is for. Reach out and we’ll help you get ahead of it.

If you’d rather have someone else handle this migration, or you’re running into a snag with your own implementation, contact our team — we’re happy to help.

Let's Architect Your Next Move

Book a discovery session with our lead engineers. In this initial strategy call, we'll discuss your specific challenges, review your current e-commerce architecture, and identify the most impactful opportunities for growth and AI-powered automation.

Vadim Côte Hi, I'm Vadim Côte Co Founder & Business Consultant
Contact Us Now!
Fill the Form ->

We’d love to hear from you—let’s build something great together.

Book a Call ->