Currently, I’m facing a problem while updating my app from non-expiring to expiring offline tokens. The setup contains an embedded app (React Router + Prisma) and a Heroku backend.
Before the April 1st update, my workflow was as follows: when the app was installed, it generated a non-expiring offline access token. I would store this on my external backend for custom task processing, while the embedded app itself used the standard shopifyApp() default authentication.
I successfully implemented the refresh token logic by sending the result of the following curl command to the backend during app installation, and it worked fine:
However, I noticed that the refresh token was constantly getting invalidated. My first thought pointed to this line in the offline access tokens documentation:
One-time use: The previous refresh token is invalidated after use.
So, here is my problem: the embedded app is also using offline token authentication (for webhooks + graphql), which seems to be invalidating the refresh token stored on my external backend.
Right now, I can’t think of a clean solution to this issue. I would like to hear if anyone else has dealt with a similar problem, or if there is something that I am missing.
Ran into this same drift on a project a few weeks back. Refresh tokens are one-time use so any second consumer always loses. Here’s what worked for me — curious if anyone has a cleaner take.
The way out was to stop storing/refreshing the offline token on the backend for anything an embedded request can reach. Instead, have the React Router frontend forward the App Bridge session token (id_token) to your Heroku backend on every admin-API request (e.g. as Authorization: Bearer <id_token> or a custom header), and on the backend do token-exchange against the inbound id_token instead of refresh_token:
POST https://{shop}/admin/oauth/access_token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token={id_token from header}
subject_token_type=urn:ietf:params:oauth:token-type:id_token
requested_token_type=urn:shopify:params:oauth:token-type:offline-access-token
client_id={…}
client_secret={…}
Token-exchange doesn’t care what state your stored refresh_token is in. It mints a fresh access+refresh pair from the id_token + your app creds, every time. So even if the embedded SDK already invalidated your copy, this still works.
For cron jobs and webhook handlers where there’s no id_token, you still need refresh_token grant — but in that case make sure your backend is the sole owner of that token. The original problem is that two parties (your backend + the SDK) are both refreshing the same one. Pick one owner, give the other a different auth path (token-exchange).
@mehdi7 That’s a very valid solution IMO! I have something similar in another project where I need to process some data on my external backend, but without needing to store the token since there’s no background task.
Regarding your suggestion, that’s exactly what I’m thinking about right now. Since I need the token on the Heroku backend, the first thing that comes to mind is to move away from the default authentication provided by the SDK (which uses Prisma for storing tokens) and use only the external backend as the single source of truth. However, I would love to know if there’s an alternative, or if this approach is truly reliable.
@devfabri Yeah, Heroku as single source of truth is the right call for your setup. The SDK’s Prisma session was always meant as the SDK’s own cache, not as a shared store with your backend — fighting it for the refresh_token is how you and I both ended up here.
Two ways to make it clean:
Drop SDK Prisma storage entirely. Point shopifyApp({ sessionStorage }) at a custom storage that proxies to Heroku via an internal API. SDK has no local token state. After OAuth, the SDK forwards the token triple to Heroku via afterAuth and that’s the last time the SDK touches the token.
Keep SDK Prisma but make it non-load-bearing. Same afterAuth push to Heroku, but you don’t replace storage. The SDK’s stored copy can age out without consequence since you never read from it — all admin-API calls forward id_token to Heroku, Heroku does token-exchange.
For webhooks, point Shopify at Heroku endpoints directly via the admin API rather than receiving them in the embedded app. Removes the SDK from the webhook path entirely.
If you’re not on it already, switching to unstable_newEmbeddedAuthStrategy + expiringOfflineAccessTokens on the SDK side reduces the conflict surface. With those flags, the SDK itself uses token-exchange for embedded requests, so it’s not rotating refresh_token on its own anymore. Doesn’t fully eliminate the two-consumer problem but shrinks it.
Went with flavor (2) on a project a few weeks back — SDK’s session row exists for OAuth bookkeeping but isn’t read for admin calls. Backend is the sole authority for offline tokens. Reliable across staging + dev shops since.