The easiest audit to write up is one of somebody else's app. This one is of our own. Catalog Guard is a Shopify app built and operated by AppPerimeter's founder. It is live in production with merchants, which makes it a fair test of our process: real code, real traffic, no client confidentiality to work around, and nobody to blame for the findings but ourselves.
We ran the same process we sell as the Shopify App Source Code Scan, with the same tools and custom rules, the same manual checklist and the same report format. This article covers what the app does, what held up, what we found, how each issue was fixed and what other Shopify app teams can take from it. Findings outside Shopify-specific code, such as general CI and hosting settings, were fixed too but are left out here.
The short version
- The core controls held up. Webhook HMAC verification, session token validation, App Proxy signatures, store isolation in every merchant-facing query and the GDPR webhooks were all implemented correctly.
- The most serious finding was a lockout, not a leak. The password unlock limit was keyed on the client IP, and behind the App Proxy and our own reverse proxies every visitor appeared to come from the same address. Five wrong guesses from anyone locked a protected page for everyone.
- The embedded admin had no
frame-ancestorsheader. That is a clickjacking risk and a documented reason Shopify rejects apps at App Store review. - Smaller gaps: shop identity on webhooks taken from an unsigned header, no freshness check on App Proxy signatures, two queries without a store filter, cached customer tags surviving a redaction request, and a secret link token left in the address bar.
- Triage was most of the work. Our custom Shopify rules produced 63 matches, and none of them was a real issue. Every finding above came from manual review.
The app under test
Catalog Guard lets a merchant restrict parts of their catalog: lock products, collections and pages behind a login, a customer tag, a password, a secret link or a date window, hide prices and block purchases. That means it touches most of the surfaces a Shopify app can have:
| Surface | How it works |
|---|---|
| Embedded admin | React UI in the Shopify admin; Laravel API authenticated with App Bridge session tokens; managed install with token exchange, so there is no OAuth callback |
| Storefront | Theme app extension scripts that ask the backend for an access decision through a signed App Proxy |
| Checkout | A cart validation Function that blocks restricted purchases inside Shopify |
| Webhooks | Resource deletions, uninstall, billing, customer updates and the three mandatory compliance topics |
| Data | Access rules per store, access events with a customer GID, hashed passwords and secret link tokens |
The scopes it requests are read_products, write_products, read_customers, read_online_store_pages, read_validations and write_validations.
How we ran the audit
The process followed our standard scan, described in detail in what a source code security review finds in a Shopify app:
1. Secrets: Gitleaks over every commit on every branch, plus Trivy on the working copy. 2. Dependencies: OSV-Scanner and Trivy over composer.lock and both package-lock.json files, then a check of what actually reaches production. The Function compiles to WebAssembly, so its npm toolchain never runs in production. 3. Static analysis: Semgrep with the standard PHP, JavaScript and OWASP rule packs, plus our custom Shopify rules for store isolation, webhook verification, shop parameter handling and embedded frontend patterns. 4. Manual review: every route and its middleware, every webhook handler, session token and App Proxy verification, store isolation in each repository method, the compliance webhooks, scopes against the API calls the code makes, and the response headers of the live app.
The code was analysed locally and no external scanning services were used.
What held up
A report that lists only problems hides what the team got right, and it makes the next review slower. Here is what we checked and found sound:
Webhooks. Every webhook route, including the compliance topics, runs behind one middleware that computes the HMAC over the raw request body and compares it with hash_equals. An empty secret or a missing header returns 401. Deliveries are deduplicated by X-Shopify-Webhook-Id. This is the pattern from our webhook HMAC verification guide.
Session tokens. The admin API decodes the App Bridge token with HS256 pinned and checks expiry with a small leeway. The audience must equal the app's client ID, the issuer and destination must point to the same shop, and that shop must match *.myshopify.com. The store is taken only from the verified token, never from a request parameter.
App Proxy. The query string signature is verified before anything else. Only then is the shop looked up, and only among installed shops. The customer ID comes only from the signed logged_in_customer_id parameter, which Shopify adds itself.
Store isolation. The app has no global scope. Instead, all database access goes through repository methods that take a shop_id, and controllers load a rule only with the current store's ID. Updating, deleting or duplicating another store's rule returns 404. Child tables are removed through cascading foreign keys when a store is deleted. See cross-store IDOR in Shopify apps for why this matters.
Storefront code. The theme extension scripts ship through Shopify's CDN and cannot be changed from the backend. They render server data with textContent, never evaluate code from a response, and validate merchant custom CSS against a strict deny list.
Compliance webhooks. shop/redact deletes the store and everything attached to it. customers/redact removes the customer identifier from access events, which is the only personal data the app stores.
Scopes. Each requested scope maps to calls the code actually makes: metafield writes, customer tag lookups, page lookups and the cart validation. None are unused.
Finding 1: one password lockout for every visitor
Severity: High (availability of a paid feature).
Merchants can protect a page with a password. To stop guessing, the backend allowed five failed attempts per minute, keyed on the rule and the client IP:
// Before: the "client IP" was the same for every storefront visitor
$key = "password-unlock:{$rule->id}:{$request->ip()}";
The problem is where that IP comes from. Storefront requests do not come from the visitor's browser. They come from Shopify's App Proxy servers, which pass the visitor's address in X-Forwarded-For. In our setup the request then went through Cloudflare and two reverse proxies. The inner proxy did not trust the outer one, so it replaced X-Forwarded-For with the outer proxy's internal address. By the time the request reached Laravel, every visitor of every store had the same "client IP".
The result was a shared counter. Five wrong passwords from anyone locked the protected page for every visitor for a minute, and a customer with the right password was told it was wrong. Anyone could keep a merchant's protected page closed with a slow loop of bad guesses. The same configuration bug would have worked the other way if the chain had been configured to trust the header from anywhere: an attacker could then send a new X-Forwarded-For on each attempt and bypass the limit entirely.
The fix:
- The proxy chain now trusts forwarded headers only from known hops, so the address Shopify reports reaches the application intact. The application uses that address only on requests whose App Proxy signature has already been verified.
- When a customer is logged in, the limit is keyed on the signed
logged_in_customer_id, which a visitor cannot change. - A separate, higher ceiling per rule caps the total guess rate however addresses are spread, and repeated failures slow down instead of locking everyone out.
The lesson: in a Shopify app, "the client IP" depends on your whole proxy chain, and your proxies decide whether it is the visitor, a proxy or whatever the attacker typed. Before you key a rate limit, a ban or an audit log on an IP, log what the application actually sees on a real storefront request.
Finding 2: the embedded admin could be framed by any site
Severity: Medium (App Store requirement).
The admin UI was served without a Content-Security-Policy header. Shopify's guidance is explicit: an app rendered in the Shopify admin must send frame-ancestors with the authenticated shop's domain and https://admin.shopify.com on every route that renders HTML, and App Store review rejects apps where it is missing or wrong.
Without it, any site can load the app in a frame and overlay it to trick a user into clicking. The practical risk was limited, because the admin API needs a session token that App Bridge only issues inside the Shopify admin. That does not change the review outcome.
The fix: the server that delivers the admin UI now sets the header per request, after validating the shop parameter against *.myshopify.com:
Content-Security-Policy: frame-ancestors https://{shop}.myshopify.com https://admin.shopify.com;
The lesson: if your admin frontend is served separately from your API, check the HTML responses as well as the API. Security headers are often configured on one and forgotten on the other.
Finding 3: webhook shop identity came from an unsigned header
Severity: Low (hardening).
The webhook middleware verified the HMAC correctly, then took the store from X-Shopify-Shop-Domain. As we covered in the HMAC guide, the signature covers only the body. The headers are not signed, and one client secret signs deliveries for every store.
Forging a delivery still requires the secret. But if a genuine body ever leaked, for example through a logging tool, it could be replayed with a different shop header. For app/uninstalled that would mark another store as uninstalled. For shop/redact it would delete another store's data.
The fix:
- The shop header must be a valid
*.myshopify.comdomain. - For destructive topics, the domain in the header must match the domain in the signed body.
shop/redactcarriesshop_domain, andapp/uninstalledcarries the shop'smyshopify_domain. - Deliveries with an
X-Shopify-Triggered-Atfar in the past are rejected.
Finding 4: App Proxy signatures did not expire
Severity: Low (hardening).
Shopify adds a timestamp to every App Proxy request and includes it in the signature. The app checked the signature but never looked at the timestamp, so a captured signed URL stayed valid forever. That matters because the signed parameters include logged_in_customer_id, which the app uses to decide what a visitor may see.
The fix: requests whose timestamp is more than a few minutes old are rejected after signature verification.
Smaller fixes
- Two queries without a store filter. Marking a deleted product as missing, and anonymising a customer after
customers/redact, matched records by Shopify GID across all stores. Shopify GIDs are globally unique, so no data crossed stores. The queries now take the store ID anyway, so every query in the app follows the same rule and a future change cannot turn them into a real cross-store bug. - Customer tags outlived a redaction request. Customer tags are cached for five minutes to keep storefront checks fast.
customers/redactcleared the database record but not the cache. It now clears both. - The secret link token stayed in the address bar. After a visitor unlocked a page with a secret link, the token stayed in the URL. There it was saved in browser history and could be sent to analytics and tracking scripts installed in the theme, which record the full page URL. The storefront script now removes the token with
history.replaceStateafter a successful unlock.
An earlier finding: redirects after a denial
This audit was not the first review of the app. An earlier manual review looked at what would happen if the backend itself were compromised. That scenario has hit Shopify apps before: one compromised config endpoint can push harmful behaviour to every store that loads the app's storefront script.
Merchants can send denied visitors to a page in their own store. The backend checked that the target was a relative path starting with / but not //. The browser, however, treats a backslash like a slash, so /\evil.example passed the check and sent visitors to another site.
That was fixed in two places. The backend now rejects backslashes and control characters. More importantly, the storefront scripts now check that the final redirect target is on the store's own domain before they follow it. Even a fully compromised backend can no longer use Catalog Guard to send a merchant's customers to another domain.
What the scanners got wrong
Our custom Semgrep rules produced 63 matches. None of them was a real issue. All were dropped, with reasons listed in the report:
| Rule | Matches | Why they were dropped |
|---|---|---|
| Route without authentication | 26 | Middleware is applied to whole route groups, which the rule did not see |
| Unvalidated shop parameter | 28 | The "shop" was the model the authentication middleware had already resolved, not user input |
| Non-constant-time signature comparison | 2 | Both verifiers used hash_equals; the rule did not follow the return statement |
| Other | 7 | An HTML entity decoder writing to a detached textarea, log lines that contain no secrets, and an aggregate query whose store filter sits a few lines below the raw SQL |
Gitleaks found one "API key" in the full history. It was the app's client ID, which is public and appears in the app configuration anyway.
This is why every scanner result in our reports is triaged by a person, and why the dropped results are listed with reasons. A raw scanner export for this app would have been 63 warnings and no findings. Scanners do not know where your client IP comes from or which headers Shopify signs. The audit also gave us calibration notes for our rules: recognise middleware applied to route groups, ignore request attributes set by authentication middleware, and flag repository methods that query without a store filter, which would have caught the unscoped queries.
Results
| Finding | Severity | Status |
|---|---|---|
| Password unlock limit shared by all visitors behind the App Proxy | High | Fixed |
No frame-ancestors on the embedded admin | Medium | Fixed |
| Webhook shop identity from an unsigned header | Low | Fixed |
| App Proxy signatures without a freshness check | Low | Fixed |
| Two queries matched by GID without a store filter | Low | Fixed |
Cached customer tags kept after customers/redact | Low | Fixed |
| Secret link token left in the URL | Low | Fixed |
| Off-site redirect through a backslash (earlier review) | Medium | Fixed |
A checklist for your own app
Most of these findings are not specific to Catalog Guard. If your app has a storefront component, check these first:
1. Log the client IP your application sees on a real App Proxy request. If it is the same for every visitor, every IP-based limit, ban and log entry you have is wrong. 2. Request your admin's HTML page with curl -I and look for frame-ancestors with the shop's domain and https://admin.shopify.com. 3. Find where your webhook handlers get the shop from. If it is a header, decide what a replayed body with a different header could do. 4. Check that App Proxy requests are rejected when the timestamp is old, not only when the signature is wrong. 5. Search for queries that match on a Shopify GID or ID without a store ID, especially in jobs triggered by webhooks. 6. Make sure customers/redact clears caches and search indexes as well as database rows. 7. Make sure tokens that grant access do not stay in URLs where theme scripts can read them.
If you would rather have someone else go through your code, our Shopify App Source Code Scan runs the same process on one repository for a fixed $150. You receive a written report with file and line references, fixes and the false positives we dropped. For apps that also need their running backend tested, see the Security Readiness review.