Every public Shopify app is a multi-tenant system. One backend serves hundreds or thousands of stores, and each store expects its orders, customers, settings and files to be invisible to every other store. When that separation fails, one merchant can read or change another merchant’s data. Often this does not take a sophisticated exploit: changing a number in a request is enough.

This class of bug is an insecure direct object reference (IDOR) that crosses a tenant boundary. We call it cross-store IDOR. It is one of the most common high-impact findings in Shopify app assessments, and one of the easiest to miss in code review. Every individual endpoint looks reasonable. The flaw is in what the endpoint does not check.

This article covers how a Shopify app knows which store is calling, the six patterns that most often break that link, a practical two-store test method and fixes that hold up.

The short version

  • A request is safe only if the store is derived from a verified source and every data access is scoped to that store.
  • Verified sources are the session token (ID token) signature, the webhook HMAC and the app proxy signature. A shop query parameter, a header or a value in the request body is not verified.
  • Globally unique or hard-to-guess IDs are not authorization. A UUID makes guessing harder; it does not stop a leaked or enumerated ID from being used by another store.
  • Testing needs two development stores with the app installed on both, and a systematic swap of identifiers between them.

How a Shopify app knows which store is calling

Shopify gives an app several authenticated channels. Each carries the shop identity in a way that can be verified with the app’s client secret:

The rule follows directly from the table: the shop identity is only trustworthy after its signature has been verified. Everything an app does later, including database queries, Admin API calls, file access and background jobs, must use that verified identity. A value the client merely claims is not enough.

Shopify’s own documentation describes these checks in detail: session tokens, webhook HMAC verification and app proxy authentication.

Six patterns behind cross-store IDOR

1. Lookup by ID without a store scope

The classic case. The endpoint authenticates the caller correctly, then loads a record by its primary key alone:

// Vulnerable: authenticated, but not scoped to the caller's store
app.get('/api/orders/:id/export', requireSession, async (req, res) => {
  const order = await db.order.findUnique({ where: { id: req.params.id } });
  res.json(order);
});

Store B is authenticated as store B, but can request /api/orders/<id belonging to store A>. The same flaw shows up in update and delete handlers, in nested resources (/api/campaigns/:id/recipients) and in bulk endpoints that accept a list of IDs.

// Scoped: the store comes from the verified session, and is part of every query
app.get('/api/orders/:id/export', requireSession, async (req, res) => {
  const order = await db.order.findFirst({
    where: { id: req.params.id, shopDomain: res.locals.shop },
  });
  if (!order) return res.sendStatus(404);
  res.json(order);
});

Return 404 instead of 403 for records that belong to another store, so the response does not confirm that the ID exists.

2. Trusting a shop parameter instead of the session token

Embedded apps receive ?shop=example.myshopify.com in the initial URL, and many frontends keep passing it to their own API. The backend then uses that parameter to decide whose data to return, or to pick which stored access token to use:

// Vulnerable: the shop is whatever the client says it is
const session = await loadOfflineSession(req.query.shop);
const client = new shopify.clients.Graphql({ session });

This is the most Shopify-specific form of the bug, and one of the most damaging. The attacker does not just read the app’s database: the app calls the Admin API of the victim store with the victim’s own access token. Depending on the app’s scopes, that can expose orders, customers or products, or allow changes to them.

The fix is to ignore client-supplied shop values for authorization entirely. Use the shop from the verified session token, or from the verified webhook or proxy request, to load the token and data.

3. Webhook handlers that skip or misplace HMAC verification

A webhook endpoint is a public URL. If it trusts X-Shopify-Shop-Domain without verifying the HMAC, anyone can send a forged event “from” any store. Common variants:

  • HMAC verification exists but runs on a re-serialized body instead of the raw bytes, fails, and someone has “temporarily” disabled it.
  • Verification is done, but the handler then uses a shop domain or ID from the payload rather than from the verified request.
  • Compliance webhooks (customers/data_request, customers/redact, shop/redact) are routed to a different handler that was never protected.

A forged shop/redact or app/uninstalled event can delete another store’s data or disable the app for it, so these handlers deserve the same review as any authenticated endpoint.

4. App proxy requests with an unverified customer or shop

App proxies forward storefront requests to the app with shop and logged_in_customer_id query parameters, plus a signature. If the app does not verify the signature, a request can claim any shop and any customer ID. That can expose another customer’s order history, loyalty balance or saved data through a storefront widget.

Even with a valid signature, check that every record you return belongs to both the signed shop and the signed customer. Valid signatures only prove that Shopify forwarded the request; they do not filter your database for you.

5. Files, exports and signed URLs

Exports, invoices, label PDFs and uploaded images often end up in object storage. Typical problems:

  • Predictable object keys such as exports/<shop_id>/<order_id>.csv, served from a public bucket.
  • A download endpoint that checks the file exists, but not that it belongs to the calling store.
  • Long-lived pre-signed URLs that end up in emails, logs or support tickets.

Treat file access as a data access: resolve the file through a store-scoped database record, then issue a short-lived link.

6. Shared caches, queues and background jobs

Tenant context is often lost outside the request cycle:

  • Cache keys that omit the store (settings:v2 instead of settings:v2:<shop>), so one store receives another store’s cached settings.
  • Jobs that carry a record ID but re-load the record without the store, or pick a store’s access token from a global “current shop” variable.
  • Aggregation or reporting code that runs across all stores and writes results back to the wrong one.

Shopify’s globally unique resource IDs do not prevent this. Uniqueness tells you which record; it says nothing about who may access it.

How to test for cross-store IDOR

A useful test needs two stores that are as similar as possible, so differences in behavior point to authorization rather than to data shape.

1. Prepare two development stores. Install the app on store A and store B with the same plan and settings. Use synthetic data only. Create the same kinds of objects in both: orders, customers, app-specific records, files and exports.

2. Map the attack surface. Capture traffic from the embedded UI, the storefront components and any public endpoints. List every endpoint that accepts an identifier: path parameters, query parameters, JSON bodies, GraphQL variables, file names and webhook payloads. Include the app’s own IDs, Shopify GIDs and numeric IDs, customer IDs and shop domains.

3. Swap identifiers systematically. Authenticated as store B, replay each request with store A’s identifiers. Cover reads, but also updates, deletes, exports, “duplicate”, “send” and any action with side effects. Then repeat in the opposite direction.

4. Tamper with the shop identity itself. With store B’s valid session token, change any shop parameter, header or body field to store A. Send webhook requests with a forged X-Shopify-Shop-Domain and no valid HMAC. They must be rejected. Modify app proxy parameters without recalculating the signature. They must be rejected too.

5. Check the indirect paths. Request store A’s export or file links while authenticated as store B. Trigger background jobs from store B and confirm they never touch store A. Look for cached responses that differ only by store.

6. Record evidence carefully. For each finding, note the request, the identifiers used, which store owned the data and what was returned or changed. Redact merchant data; synthetic data makes this much easier.

Our approach combines this manual testing with automated checks and in-house AI-assisted analysis that helps us map routes and compare how store context is checked across endpoints. Every reported finding is reproduced manually across two stores.

Fixes that hold up

  • Derive the store once, from a verified source. Authenticate the session token, webhook HMAC or proxy signature in one piece of middleware and expose a single verified shop value to the rest of the app.
  • Make store scoping the default. Use a repository layer, ORM scopes or query helpers that require a store. In PostgreSQL, row-level security with a per-request store setting adds a second line of defense.
  • Load access tokens only by verified shop. Never pick a stored token from a client-supplied value.
  • Verify webhooks on the raw body with a constant-time comparison, and cover compliance webhooks with the same handler.
  • Scope files through the database and issue short-lived links.
  • Put the store in every cache key and job payload, and re-check it when the job runs.
  • Add cross-tenant tests to CI. For each endpoint that takes an identifier, create the object as store A and assert that store B gets a 404. These tests are cheap and catch regressions that code review misses.

The OWASP IDOR Prevention Cheat Sheet covers the general principles; the Shopify-specific part is knowing which identity sources are verified.

Quick checklist

  • Session tokens are verified on every API request; the shop query parameter is never used for authorization.
  • Every query, update and delete is filtered by the verified store.
  • Access tokens are loaded only for the verified store.
  • Webhook HMAC is verified on the raw body for every topic, including compliance webhooks.
  • App proxy signatures are verified, and returned data is filtered by both shop and customer.
  • Files and exports are resolved through store-scoped records and served with short-lived links.
  • Cache keys and background jobs include the store.
  • CI contains cross-store tests for endpoints that accept identifiers.

When to get an independent test

Cross-store issues are hard to rule out from inside the team that wrote the code, because the missing check is easy to read past. If you are preparing for review or have changed how your app handles sessions, files or webhooks, our Shopify App Security Readiness review includes sampled cross-store authorization checks across two test stores. If Shopify has already asked for an incident report and independent testing, see our Shopify App Incident VAPT.