We’ve shipped an update that makes refresh-token rotation more resilient when a refresh succeeds but an app doesn’t receive or persist the response.
We heard a recurring concern: a network interruption, worker failure, or database write failure could leave an app holding an older refresh token after Shopify had returned a new token pair. Once the former retry window passed, the app could be unable to refresh its offline token until a merchant opened the app again.
With this update, if an app still has the refresh token it used because the refreshed pair was lost or wasn’t saved, it can retry that older refresh token to recover a valid token pair. The older refresh token remains usable until the app begins using the newly issued refresh token.
There are deliberate limits:
The recovery period is up to 30 days from the original refresh token’s first use.
It doesn’t extend a refresh token beyond its normal 90-day lifetime.
Once an app uses the replacement refresh token, the prior token is retired.
Continue to treat each refresh response as an access-token/refresh-token pair: serialize refresh attempts for each shop, persist the returned pair atomically, and use the newest refresh token for the next refresh. This change provides a recovery path for lost responses; it doesn’t make long-expired or previously retired refresh tokens valid again.
For implementation guidance, see our docs on access token refresh for more details.
@TerenceShopify thank you. Now I think that because everyone who has a background running app is going to implement this. It would be helpful to share a diagram or implementation specification of the process: which values to save in the db (token, date..) and when, for how long…When to delete tokens. In case of a failure what to do, how to retry, and what happens in case of a retry if a running runtime already has a token that is refreshed mid-process, etc…
Not that it’s too complex, but basically we’re all going to implement the same thing, just tailored to our own infrastructure. We don’t need to get creative when implementing this, instead we need to implement it reliably because even a tiny mistake would have strong consequences.
If we just have to follow a blueprint it would help shipping with confidence and focus on the implementation details and not the design.
The “old token stays valid until you use the new one” behaviour does not apply when you migrate from a non-expiring (permanent) offline token to an expiring one.
That only applies to refresh token rotation.
We tested migrate on a test store. The old permanent api token worked, we exchanged it, then we called the API with the same old permanent api token without using the new one. It was already dead.
# old forever token still works
curl -X POST "https://sherwins-shop.myshopify.com/admin/api/2025-10/graphql.json"
-H "Content-Type: application/json"
-H "X-Shopify-Access-Token: shpat_1083..."
-d '{"query":"{ shop { name } }"}'
# {"data":{"shop":{"name":"Sherwin's Shop"}}, ...}
# migrate
curl -X POST "https://sherwins-shop.myshopify.com/admin/oauth/access_token"
-H "Content-Type: application/x-www-form-urlencoded"
-d "client_id=...&client_secret=...&grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=shpat_1083...&subject_token_type=urn:shopify:params:oauth:token-type:offline-access-token&requested_token_type=urn:shopify:params:oauth:token-type:offline-access-token&expiring=1"
# {"access_token":"shpat_fd...","expires_in":3599,"refresh_token":"shprt_d9e...","refresh_token_expires_in":7775999}
# same old token, we never used the new shpat\_
curl -X POST "https://sherwins-shop.myshopify.com/admin/api/2025-10/graphql.json"
-H "Content-Type: application/json"
-H "X-Shopify-Access-Token: shpat_1083..."
-d '{"query":"{ shop { name } }"}'
# {"errors":"\[API\] Invalid API key or access token (unrecognized login or wrong password)"}
So if migrate succeeds and you fail to save the new pair, you can’t retry with the old token. The shop is stuck until the merchant opens the app again.
Refresh Token is different. If you refresh with R1 and get R2, R1 still works until you actually use R2.
Can the same recovery window apply to migrate as well? Keep the old permanent token valid until the app uses the new refresh token.
Or a 30-day grace period on the old permanent api token up to the Jan 1, 2027 deadline would help. Right now one failed DB write after migrate breaks the shop with no retry.