Hello!
I ran into this issue while clearing up some HMAC validation failures, in particular when the URL contains some additional query parameters for filtering/sorting etc, that are unpacked as arrays/hashes.
Summary
When an embedded app is loaded with nested/bracket query parameters (e.g. foo[a]=…&foo[b]=…) whose keys are not in alphabetical order, the hmac query parameter cannot be verified by the app. Shopify computes the HMAC using the parameters in their original (insertion) order, but the iframe URL Shopify delivers to the app has the nested keys re-sorted alphabetically. Since the app only ever sees the delivered (re-sorted) URL, it cannot reconstruct the exact message that was signed, and HMAC verification fails for a perfectly legitimate request.
Environment
-
Embedded app loaded inside Shopify admin (App Bridge); standard request
hmacquery-param verification (not the session token /id_token). -
Reproduced on a development store.
Steps to reproduce
-
From within Shopify admin, navigate to an embedded app deep link whose query string contains a nested/bracket param with keys in non-alphabetical order. Example (a date-range filter):
…/apps/<app>/<path>?page=1&startedAt[before]=2026-06-02 21:00:00&startedAt[after]=2026-05-31 21:00:00(note:
beforeprecedesafter) -
Shopify renders the embedded app iframe with a signed URL. The nested keys are re-ordered alphabetically in the delivered URL:
https://<app-host>/<path>?embedded=1&hmac=<hmac>&host=<host>&id_token=<jwt>&locale=en&page=1&session=<session>&shop=<shop>.myshopify.com&startedAt[after]=2026-05-31+21%3A00%3A00&startedAt[before]=2026-06-02+21%3A00%3A00×tamp=<ts>(now
afterprecedesbefore) -
In the app, verify
hmacby reconstructing the signed message from the received params, per the standard process.
Expected
The HMAC verifies — the parameters delivered to the app reproduce the HMAC that was signed.
Actual
The HMAC does not verify. Brute-forcing the message construction against the received hmac (HMAC-SHA256 with our app secret) shows the only message that reproduces the signature uses the nested hash in its original order:
startedAt={"before" => "2026-06-02 21:00:00", "after" => "2026-05-31 21:00:00"}
i.e. the order from step 1 — not the alphabetical order delivered in step 2. (All values are otherwise correct: URL-decoded, with literal space and colon.)
Confirmation
Repeating step 1 with the keys already in alphabetical order (startedAt[after] before startedAt[before]) makes the delivered order equal the signed order, and verification succeeds. So the only variable is the ordering of the nested keys.
Likely cause
The HMAC message reflects the parsed structure’s natural (insertion) ordering, while the URL delivered to the app serializes nested params with keys sorted alphabetically (e.g. Hash#to_query sorts keys, whereas the value interpolated into the signed message uses the hash’s insertion order). The two diverge whenever the original key order is non-alphabetical.
Impact
Embedded apps cannot authenticate legitimate requests that carry nested/bracket query params in non-alphabetical order — common for range filters (before/after), multi-field filters, sort specs, etc. Affected merchants hit a failed auth / blank app screen on those deep links, with no app-side remedy (the signing order is unrecoverable from the delivered URL).