Shopify's Expiring Access Tokens in Practice Refresh, Storage, and What Scope Changes Actually Do

Technology Aug 2026 8 min read
The Shopify logo above the title 'Shopify's Expiring Access Tokens in Practice', subtitled 'Refresh, Storage, and What Scope Changes Actually Do', on a dark teal field.

In December 2025, Shopify shipped a quiet change with a loud deadline: offline access tokens can now expire, and for public apps this stops being optional — apps created after April 1, 2026 must use expiring tokens, and all remaining public apps must migrate by January 1, 2027, when non-expiring token requests start returning errors (Custom and merchant-created apps are exempt).

This post is the practical guide on how token acquisition works for embedded apps today, the refresh workflow with its exact error semantics, what to store in your database — and then the interesting part: a series of experiments with a probe app that answered questions the documentation is silent on, with some results that genuinely surprised me.

This post is part of my series on Shopify with the first one covering Shopify-managed installations and how your backend detects installs without OAuth callbacks. Feel free to check out the first post.

Token exchange in one minute

Embedded apps under managed installation don’t do OAuth redirects. The frontend always holds a session token (a one-minute JWT from App Bridge, automatically attached to requests to your backend). Your backend swaps it for an API token:

POST https://{shop}.myshopify.com/admin/oauth/access_token
client_id=...&client_secret=...
&grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<session token>
&subject_token_type=urn:ietf:params:oauth:token-type:id_token
&requested_token_type=urn:shopify:params:oauth:token-type:offline-access-token
&expiring=1

Up until recently, your exchanged tokens lived forever. With expiring=1 you get the new-style response and a time-bound access token:

{
"access_token": "shpua_...",
"expires_in": 3599,
"refresh_token": "shprt_...",
"refresh_token_expires_in": 7775999,
"scope": "read_products"
}

One hour of access, ninety days of refresh. Two details worth knowing: only one refreshable expiring offline token exists per app+store — minting a new one retires the old — and in my testing the access tokens came prefixed shpua_, not the shpat_ you’ll see in older examples.

(There’s no GraphQL alternative for these calls, by design — the OAuth endpoint produces the Admin API credential, so it can’t be an Admin API mutation. Even a fully GraphQL app keeps one small form-encoded HTTP client for token exchange and refresh.)

What to store

With expiring tokens, there is more data to store so that your backend can properly rorate your tokens.

Per shop:

shop_domain TEXT UNIQUE NOT NULL -- your lookup key
access_token TEXT -- encrypt at rest
access_token_expires_at TIMESTAMPTZ -- now() + expires_in
refresh_token TEXT -- encrypt at rest
refresh_token_expires_at TIMESTAMPTZ -- now() + refresh_token_expires_in
scope TEXT -- bookkeeping (see the experiments!)

Store absolute timestamps computed at receipt, not raw TTLs. Encrypt both tokens — a leaked refresh token is ninety days of shop access. And note the comment on scope: I called it “bookkeeping” for reasons the experiments below make clear.

The refresh workflow

There are multiple ways to implement token refreshing. I would go for a service that takes care of that in the background. If your backend used a token that was just refreshed, your should implement a retry logic which will grab the latest token. I leave that implementation to you but let’s take a look at the refresh logic itself.

refresh(shop):
POST /admin/oauth/access_token
grant_type=refresh_token & refresh_token=... & client credentials
persist BOTH new tokens atomically.

The error semantics are precisely documented and worth encoding exactly:

  • Transient failures (5xx, 429, timeouts): retry once — Shopify replays the same refreshed response for up to one hour, so a retry after a network failure cannot lose the rotated tokens.
  • **401 with error: "invalid_request"**: the refresh token is permanently dead. Clear stored tokens; recovery requires a fresh session token, i.e., the merchant opening the app again — at which point your normal token-exchange path re-runs.
  • The old access token survives until its original expiry, but the old refresh token dies the instant the new one is issued — hence “persist both atomically.”

The single-flight lock isn’t optional: with one expiring token per app+store, two concurrent refreshes retire each other’s tokens and you chase ghost 401s.

Migrating from non-expiring tokens

There is wonderful official article covering the migration but let me highlight the most important items.

Be careful when migrating as there is no turning back - or at least an easy way back.

The actual migration is just a simple POST call to https://{shop}.myshopify.com/admin/oauth/access_token with the following important paramenters:

  • grant_type = urn:ietf:params:oauth:grant-type
  • subject_token = your current non-expiring token
  • subject_token_type = urn:shopify:params:oauth:token-type
  • expiring = Must be set to 1 to request an expiring offline token.

Once the request is successful, the old token is revoked.

The experiments

Now the fun part. Two questions came up during my migration that the documentation doesn’t answer:

  1. After a merchant grants an additional scope, does refreshing (no session token involved — the pure background path) return a token with the new scope?
  2. Can the backend rely on the app/scopes_update** webhook** to know when to act?

Rather than guess, I built a throwaway probe app with the help of Claude — the official Remix template plus a page of buttons that make raw OAuth and Admin API calls, bypassing all framework token management, logging every request, response, and webhook arrival to a timestamped ledger. The app was configured with scopes = "read_products" and optional_scopes = ["read_orders", "read_customers"], using Shopify’s optional-scopes feature and the App Bridge Scopes API (shopify.scopes.query() / request() / revoke()).

The probe app is public — you can run every experiment yourself: github.com/milannankov/shopify-scope-probe.

Finding 1: scopes are enforced live — tokens are identity, not permissions

This one overturned my mental model. The sequence, from the ledger:

19:41:16 token issued, scope: "read_products"
19:42:08 merchant grants read_orders (scopes.request → granted-all)
19:43:27 the SAME pre-grant token reads orders successfully

The token minted before the grant could read orders after the grant — no refresh, no re-exchange, nothing. And it’s symmetric:

19:52:34 token (scope string includes read_orders) reads orders: OK
19:52:43 merchant revokes read_orders
19:52:52 the SAME token: orders → ACCESS_DENIED (9 seconds!)

Shopify evaluates authorization on every API call against the installation’s current grant record. The scope field in a token response is a snapshot of that record at issuance — it is not what the token is bound to. Access tokens are identity credentials, not permission containers.

The practical consequences are significant:

  • After an optional-scope grant, you don’t need to touch your tokens at all. The moment scopes.request() resolves with granted-all, your existing stored token works for the new scope.
  • After a revoke, your stored token silently loses capability within seconds. ACCESS_DENIED is a live possibility on any call your backend makes, forever — handle it as such, and treat it as your de-facto revoke-detection signal.
  • The scope column in your database is bookkeeping for UX decisions (which features to show), not an enforcement boundary — and it self-heals, because…

Finding 2: refresh reports current grants

The refresh performed after the grant — pure grant_type=refresh_token, no session token anywhere near it — returned scope: "read_products,read_orders". So yes, refresh “picks up” scope changes. Given Finding 1, this is bookkeeping rather than propagation (the token would have had the access anyway), but it means your stored scope metadata corrects itself on every rotation.

Finding 3: the webhook the docs point you to doesn’t fire for optional scopes

The docs tell you to subscribe to app/scopes_update to react to scope changes, for required and optional scopes alike. Measured reality, across two runs on my dev store:

TriggerWebhook delivered?
Required-scope change (deploy + merchant approval)Yes
scopes.request() grantNever (2 attempts)
scopes.revoke()Never (2 attempts)

Before drawing conclusions I verified the delivery pipeline end to end: a synthetic shopify webhook trigger delivery arrived and passed HMAC validation, the tunnel endpoint was publicly reachable, and — decisive — the dashboard reported zero failed deliveries. The missing webhooks were never sent, not lost in transit.

Fair caveats: one dev store, one day, API version 2026-01, n=2. This could be a bug rather than intended behavior. But the architectural lesson holds either way: don’t make app/scopes_update the sole trigger for anything related to optional scopes. Act on the scopes.request() Promise resolution client-side, and when your backend needs ground truth, poll it:

{ currentAppInstallation { accessScopes { handle } } }

That query reflects live grant state within seconds, works with any valid token regardless of the token’s own issuance scopes, and is the reliable fallback the webhook turned out not to be.

What I’d tell a team migrating today

  1. Switch to expiring=1 now; the deadline will arrive faster than your backlog shrinks, and the background migration path (old token as subject) makes it painless.
  2. Treat scopes as a live, per-call property of the installation, not of your tokens. Gate features on your bookkeeping, but always be prepared for ACCESS_DENIED.
  3. Use the webhook for what it demonstrably does (deploy-driven scope changes, install grants) and nothing more. When the docs are silent, measure. The probe app took an afternoon and answered questions that would otherwise have shipped as wrong assumptions.

The probe app with the full experiment protocol and event ledgers is at github.com/milannankov/shopify-scope-probe. The companion post on managed installation and install detection is here.

Comments