Migrate to Shopify Expiring Offline Access Tokens

Public Shopify apps have until 1 January 2027 to stop using non-expiring offline access tokens. After that date the GraphQL Admin API rejects them with a 403 and the merchant loses your app until they re-authenticate. Two changes get you clear: request expiring tokens on every new grant, and cycle the tokens you already hold.

Neither one requires a reinstall. Shopify’s position is that the swap happens in your code, with no merchant involved.

What actually changes on 1 January 2027

Shopify set the date in a changelog entry dated 20 May 2026, which extended an earlier rule from 1 April 2026 that had only bound public apps created on or after that day. Now it covers every public app, however old.

Custom apps are exempt, as are apps a merchant created themselves in the Dev Dashboard or the admin. Enforcement lands on GraphQL Admin API requests specifically.

The token in question is the shpat_ string your app stored at install and never thought about again, valid until the merchant uninstalled or you revoked the client secret. Shopify’s stated reason for killing it is leak blast radius: a non-expiring token that escapes stays useful forever, while an expiring one dies in sixty minutes and rotates on its own.

How do you stop issuing non-expiring tokens?

It depends on how your app acquires tokens today, and there are three answers.

If it was scaffolded from @shopify/shopify-app-remix or @shopify/shopify-app-react-router, it is one future flag:

// shopify.server.ts
const shopify = shopifyApp({
  // ...
  future: {
    expiringOfflineAccessTokens: true,
  },
});

Then shopify app deploy. The template does the storage and the refreshing. Apps scaffolded recently already ship with the flag on, so check before you add it.

If you call the token endpoint yourself, add expiring=1 to the exchange:

curl -X POST https://{shop}.myshopify.com/admin/oauth/access_token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -H 'Accept: application/json' \
  -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
  -d 'subject_token={id_token}' \
  -d 'subject_token_type=urn:ietf:params:oauth:token-type:id_token' \
  -d 'client_id={client_id}' \
  -d 'client_secret={client_secret}' \
  -d 'expiring=1'

The response now carries four values you have to keep, not one. Store access_token, refresh_token, expires_in (3600) and refresh_token_expires_in (7776000, so ninety days). If your sessions table has a single access_token column and nothing else, that schema change is the real work in this migration.

Standalone apps on the authorization code grant put the same expiring=1 on the request that trades the code for a token.

How do you cycle the tokens you already issued?

Most of them cycle themselves. The next time a merchant opens your app and your code requests a token, the token that comes back expires. No prompt, no per-merchant action, no reinstall.

That leaves the stores where nobody opens anything. A webhook consumer, an App Proxy backend, a Flow action: all of them run with no merchant in the admin and no ID token to exchange, so they will sit on a non-expiring token until it stops working. Anything you run on a schedule falls in the same bucket, including a nightly Admin API bulk export.

For those, cycle from a background job using the stored token itself as the subject:

curl -X POST https://{shop}.myshopify.com/admin/oauth/access_token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
  -d 'client_id={client_id}' \
  -d 'client_secret={client_secret}' \
  -d 'subject_token={non_expiring_offline_token}' \
  -d 'subject_token_type=urn:shopify:params:oauth:token-type:offline-access-token' \
  -d 'requested_token_type=urn:shopify:params:oauth:token-type:offline-access-token' \
  -d 'expiring=1'

Both requested_token_type and expiring=1 are required in this shape. Online tokens, delegate tokens, scope-restricted tokens and tokens that already expire all get rejected.

Read the caution in Shopify’s migration guide before you write the loop. Shopify destroys the non-expiring token in the same transaction that issues the expiring one, so the call is not safe to replay. Lose the response, or crash between the HTTP call and the database write, and that store has no usable token until the merchant reauthorises. Persist the new pair before you mark a store done. A store you have already cycled comes back as 400 Bad Request with {"error": "invalid_subject_token"}, which is also what a bad parameter looks like, so log the request body alongside it.

How does refreshing an expiring offline token work?

Same endpoint, ordinary OAuth refresh grant, no ID token involved:

curl -X POST https://{shop}.myshopify.com/admin/oauth/access_token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -H 'Accept: application/json' \
  -d 'client_id={client_id}' \
  -d 'client_secret={client_secret}' \
  -d 'grant_type=refresh_token' \
  -d 'refresh_token={refresh_token}'
{
  "access_token": "shpat_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
  "expires_in": 3600,
  "refresh_token": "shprt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
  "refresh_token_expires_in": 7776000,
  "scope": "write_products,read_orders"
}

Every refresh mints a new refresh token. Use the new one next time.

The rotation rule is more forgiving than it first looks. The refresh token you presented is not retired the instant Shopify issues its replacement. It stays usable until your app actually uses the newer one, or thirty days after its first use, or its original ninety-day expiry, whichever arrives first. Shopify widened that recovery path in a changelog on 28 August 2026, specifically so a lost response or a failed write does not strand a shop. Retry with the token you still hold.

Two operational rules fall out of it. Serialise refreshes per shop, and persist each returned pair atomically.

One more, easy to get wrong in an app that does both foreground and background work: never refresh and re-run token exchange for the same store at the same time. Each retires the other’s result, so one of the two tokens you receive is dead on arrival. When a merchant session exists, re-run token exchange with the ID token on that request, and keep the refresh grant for the jobs that have no ID token to work with.

Which errors mean what

After the deadline, a request presenting a token that never cycled returns 403 Forbidden, with Non-expiring access tokens are no longer accepted for the Admin API inside errors followed by a link. Match on that phrase rather than the whole string, because a missing access scope also returns 403 and the two want different handling.

A refresh token that has expired, or that your app already replaced, returns 401 Unauthorized with {"error": "invalid_request"}. There is no recovery in code: clear the stored pair and wait for the merchant to open the app so you can mint a new one.

The 400 on token exchange is the routine one. ID tokens live about a minute, so an expired one is normal traffic and not a fault. Answer it with a 401 plus the X-Shopify-Retry-Invalid-Session-Request header and App Bridge fetches a fresh ID token and retries. Keep 5xx for things a new ID token cannot fix, like rejected client credentials. It is the same status-code discipline our guide to verifying Shopify webhooks applies to delivery: the code you return decides what the platform does next.

What we would do first

Flip the flag now and let attrition do the rest. On a template app it is a one-line change, a deploy, and a look at your session storage to confirm the refresh token is actually landing in a column. Then leave normal traffic to cycle the installs that get opened, and wait a month before you look at what is left.

Write the background cycler only for the remainder, and only once you can prove the write path is atomic, because that job is the one that can lock a merchant out. Sort by last-opened date, run it in small batches, and check the count of remaining non-expiring tokens after each batch rather than trusting the job’s own success log.

If your app is a custom app, or one a merchant built in their own admin, none of this is compulsory. Expiring tokens are still the better default for anything holding a write_ scope, but that is a choice rather than a deadline, and there is no reason to do it in the same week.

Whoooop builds and maintains Shopify apps for UK merchants and agencies, which includes unglamorous work like this: token storage, refresh loops and the background jobs that run when nobody is watching. If you have an app on the store with a deadline attached to it, our Shopify development work covers the audit as well as the fix.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch