My embedded app’s Admin GraphQL API calls are all being rejected with a 403 and an empty error body — this happens even for the simplest possible query:
query { shop { name } }
Response:
{“errors”:{“networkStatusCode”:403,“message”:"GraphQL Client: ",“response”:{}}}
What I’ve already confirmed and ruled out:
- Scopes are granted: Products and Online Store both show “View ✓” under the app’s permissions in the store admin
- API secret in my hosting environment matches exactly what’s shown in the Partner Dashboard / Dev Dashboard
- The dev store itself is active and healthy (confirmed via Partner Dashboard)
- The app was freshly uninstalled and reinstalled to force re-consent, which correctly showed a real permissions screen (Products, Online Store) on reinstall — not an empty one
- This isn’t limited to one query type — even the most basic possible query fails the same way
App details:
- Framework: @shopify/shopify-app-react-router v2
- Auth strategy: unstable_newEmbeddedAuthStrategy: true
- API version: 2026-07 (ApiVersion.July26)
- Distribution: AppStore (unlisted/dev store install)
- Client ID: 0fff054674e7ee8bdb015dafe6b4e10e
- Store: shopboost-labs-hclkenol.myshopify.com
Server logs show authentication succeeding and a new offline access token being issued on every request (via token exchange), but the very next GraphQL call using that token gets rejected with the 403 shown above. This happens consistently, not intermittently.
Any guidance on what specifically causes a blanket 403 (all queries, empty body) at the GraphQL Client level when scopes/secret/store all check out would be hugely appreciated.
Hello @Samuel_Roy
A brand new offline token minted on every single request instead of a cached one is the real tell here — it means the session isn’t persisting, so trace exactly which shop domain gets paired with that token when the GraphQL call actually goes out. This exact pattern (token exchange succeeds, then the very next call gets a 403 with an empty body, even on shop { name }) is almost always a shop-domain mismatch: the store domain used to build/call the client differs slightly (stale value, wrong case, trailing slash, or a hardcoded string) from the shop the token was actually issued for.
If you’re calling admin.graphql() straight off the admin object returned by authenticate.admin(request), that binding is handled for you, so worth checking you’re not building a separate client somewhere using a shop value from your own DB/state instead of the one on the verified request. Also confirm your SessionStorage adapter is actually reading/writing correctly, since a healthy setup should reuse a cached session token, not re-exchange on every call.
Cheers
One additional check fits this symptom closely: in shopify.server, do you have future: { expiringOfflineAccessTokens: true }, and can your Session table actually persist refreshToken and refreshTokenExpires? Shopify’s current React Router template enables that flag, and its changelog added those two session fields for token rotation.
There has also been a Shopify developer-community case where token exchange appeared to succeed, but the app minted a non-expiring offline token and every Admin API call returned the same empty 403. Enabling expiring offline tokens, migrating the session schema, removing the old session rows, and reinstalling resolved it.
Since your logs show a fresh offline token on every request, inspect one stored session after exchange. If expires, refreshToken, or refreshTokenExpires is missing, I would fix that migration/config first, clear only that dev store’s stale session rows, then reinstall and retry shop { name }. If all three are present, the shop-domain/session-persistence path in the reply above is the next check. I would treat this as a targeted test, not a guaranteed diagnosis.
Thank you both — this was it. 
expiringOfflineAccessTokens was indeed missing from my future config, and checking the actual session row confirmed exactly what you predicted: expires: null and refreshToken: null on a token that had clearly been minted fresh on every single request (matching my logs showing config.future.expiringOfflineAccessTokens undefined).
Fix that worked:
- Added
expiringOfflineAccessTokens: true to the future block in shopify.server.js
- Cleared the stale session row from the database
- Uninstalled and reinstalled the app to force a fresh, properly-expiring token exchange
shop { name } returned real data immediately after, and a full audit against live store data worked perfectly.
Really appreciate you both diagnosing this from the symptom pattern alone — saved me from a much longer dead end. This’ll hopefully help the next person who hits the same empty-403 wall.