[BUG REPORT] Inconsistent behavior between development and regular stores when using `app.scopes`

Bug: app.scopes.query() and app.scopes.request() are inconsistent on development stores

Summary

On development stores, App Bridge’s scopes.query() reports optional scopes as
“granted” before scopes.request() has ever been called. However, the app’s
offline access token does not actually include those scopes until
scopes.request() is explicitly called. This creates a silent mismatch between
what the client-side API reports and what the server-side token can actually do.

On non-development stores, this inconsistency does not occur — scopes.query()
correctly reports optional scopes as not granted until the merchant approves
them via scopes.request().

Expected behavior

scopes.query() should reflect the actual state of the offline access token. If
optional scopes have not been requested via scopes.request(), they should not
appear in the granted array — regardless of store type.

Alternatively, if development stores are intentionally auto-granting optional
scopes in scopes.query(), the offline access token should also include those
scopes automatically.

Actual behavior

  1. App is installed on a development store with optional scopes declared in the
    app config
  2. app.scopes.query() returns those optional scopes in the granted array
  3. App trusts this response and skips calling app.scopes.request()
  4. The offline access token does not include those scopes
  5. All server-side API calls using those scopes fail

Impact

Any app that uses scopes.query() to conditionally call scopes.request() — a
reasonable pattern for handling re-installs or merchants revisiting a
permissions flow — will silently break on development stores. The app appears to
have permissions but API calls fail, with no indication to the merchant or
developer that scopes were not actually granted to the token.

Workaround

Always call scopes.request() unconditionally, regardless of what scopes.query()
returns. On development stores this resolves immediately without a
merchant-facing prompt.

:waving_hand: Hey there Dylan, thanks for reporting on this. I tested this quickly yesterday and wasn’t able to reproduce. I wonder if maybe there’s a dev trick/wrinkle that could be leading to you seeing this:

First potential clue here. :slight_smile: Development stores are used with shopify app dev, which automatically grants access to declared scopes.

I could imagine a scenario* where you had something like

scopes = "write_products"
optional_scopes = [''] # or undefined / not present

…then ran shopify app dev, and with that running, modified the TOML to

scopes = ""
optional_scopes = ['write_products']

…that this could conceivably retain write_products as granted. (BTW: that’s the same behavior as production, if access to a required scope has been granted by a merchant, moving from required scopes to optional_scopes does not force that merchant to re-grant.)

* = This scenario is untested but based on my experience building optional scopes. Not claiming this is what happened, so much as this concept could help connect the dots. We’ll want to actually debug this, ideally with a minimum reproducible example.

What we’ll need to investigate this

It may be helpful to test this with a new app, shopify app init, and just keep it simple with 1-2 access scope handles being used in app config.

Could you write a specific and linear minimum reproducible example? I’m wondering:

  1. What is the starting app config state
  2. What is the starting shopify.scopes.request() return value
  3. when is shopify app dev being run (and terminated, if so)
  4. is app config TOML changed, and if so, to what?
  5. when are you calling await shopify.scopes.request() and seeing a non-granted optional scope show up as granted?

Thanks for getting back to me @Kyle-Shopify

First potential clue here. :slight_smile: Development stores are used with shopify app dev, which automatically grants access to declared scopes.

I could have been more clear. This is a development store trying out our production app. This is very much allowed and is pretty standard practice for Shopify Partners such as agencies to spin up development stores to test out apps on the App Store before making reccomendations to clients.

So that scenario you mentioned where optional_scopes = ‘‘ isn’t relevant to this case. This is a production app that has optional_scopes populated.

So the reproduction case is simply:

  1. Create a Shopify app with optional scopes including write_products for example
  2. Deploy it
  3. Install it using a development store
  4. Observe app.scopes.query() returns those optional scopes in the granted array even though they were never granted