OAuth callback hmac never validates for a Dev Dashboard app using legacy install flow

The hmac on our OAuth callback does not validate against either of our app’s client secrets, under any message construction we can find, including Shopify’s own reference implementation. We have eliminated everything on our side and would appreciate a pointer.

App

  • Client ID: 50b7ebd2d34092f0e1432532838185d7
  • Created in the Dev Dashboard, “Start from Dev Dashboard”
  • Distribution: not yet listed. Standalone, not embedded.
  • “Use legacy install flow”: ON
  • Scopes: read_products,read_orders,read_inventory,read_locations
  • Webhooks API version: 2026-07
  • Test store: a development store on the Basic plan

The authorize redirect works, the merchant sees the permission screen, approves, and Shopify redirects to our callback with code, hmac, host, shop, state and timestamp. The hmac never validates.

A real callback (authorization code redacted, it is spent):

code=<redacted>

hmac=613543f02106e95db0d95da8b0254d631f17ae9bd8bb8a8419a7843231066b89

host=YWRtaW4uc2hvcGlmeS5jb20vc3RvcmUvcG9wLXVwLWRldi1zdG9yZS1kaGtsZnc3dw

shop=pop-up-dev-store-dhklfw7w.myshopify.com

state=join.QUFBQQ.DDDD%7Cexp%252Bpopup-analytics%253A%252F%252Fprofile

timestamp=1790175642

What we have already ruled out

  1. The secret is correct. Our stored secret is byte for byte the one the Dev Dashboard shows (independent SHA-256 fingerprints match). Your token endpoint accepts it: posting to /admin/oauth/access_token with our client id and secret and a deliberately invalid code returns invalid_request: The authorization code was not found or was already used. A deliberately wrong secret returns Missing or invalid client secret, and a wrong client id returns application_cannot_be_found. So the credentials are valid and the error is specific.

  2. Both secrets fail. We rotated, so the app currently has two live secrets (“Old” and “New”). A callback captured while both were live validates against neither.

  3. It is not the message construction. We tested every non-empty subset of the five parameters, with values both as they appear in the query string and URL-decoded, joined with &, with no separator, with a comma and with a newline, against both secrets, with and without the shpss_ prefix. 992 combinations. None match.

  4. It is not a re-authorisation quirk. We uninstalled the app from the development store and did a clean install. Identical failure.

  5. Your own library’s algorithm fails. We replicated stringifyQueryForAdmin from @shopify/shopify-api (lib/utils/hmac-validator.ts) exactly, including appending to URLSearchParams in localeCompare key order and calling stringify(true), then createSHA256HMAC(apiSecretKey, queryString, Hex). It does not match either secret on two separate real callbacks.

  6. Our HMAC code is correct. Verified against RFC 4231 test case 2.

  7. The query arrives intact. We verified the raw query string our server receives is byte for byte what the browser was redirected to, so nothing in front of our app is rewriting it.

Questions

  1. Which key signs the OAuth callback hmac for an app created in the Dev Dashboard? Is it the client secret shown in Settings, or something else?
  2. Does the callback hmac follow the webhook rule of signing with the oldest unrevoked secret, or the auth code flow rule of using the newest? The rotate dialog says webhooks use the oldest until revoked, but does not say which applies to the callback.
  3. Is there any additional propagation delay for a newly created Dev Dashboard app before its callback signatures become valid?
2 Likes