Skip to main content

Following security best practices

Millions of merchants and buyers trust Shopify to run their business and protect their financial information. When a merchant installs your app or publishes your theme, we extend that trust to you, and we expect you to guard it as carefully as we do.

Most attacks aren't clever. They target the easiest and most rudimentary lapses in practice, so keeping your code secure is mostly a matter of a few habits applied consistently. A shortcut that leads to a security vulnerability later costs far more than doing the work the first time. This guide covers what goes wrong most often in Shopify apps and themes, why it goes wrong, and what to do instead.

If you have questions, need help, or want to report a security incident, then contact security@shopify.com.


  • Treat all external input as untrusted until you've verified it, including input from Shopify.
  • Derive the shop, its resources, and the actions you take on them from an authenticated session, never from a request parameter.
  • Escape untrusted content for the exact context you're rendering it into.
  • Anything you ship as part of your app or theme can end up public. Keep secrets and sensitive information out of any submitted code.
  • Don't collect personally identifiable information without express consent, and even then collect only the minimum you need.
  • When you operate your own infrastructure, expose nothing to the internet except a hardened web server on specific ports.
  • Keep dependencies few, pinned, and reviewed.
  • Treat AI output as untrusted input, and give AI systems as little authority as possible.
  • Be ready to revoke or rotate credentials and secrets in minutes, not days, if a compromise does happen.

Anchor to Common web application vulnerabilitiesCommon web application vulnerabilities

The OWASP Top 10 and the OWASP Cheat Sheet Series are the standard references for web security, and the free Web Security Academy has interactive labs if you want to practice. What follows is narrower: the issues we actually see in Shopify apps and themes. The subsections below are ordered roughly by how often they show up.

Anchor to Broken access control and tenant isolationBroken access control and tenant isolation

Authentication (who are you?) and authorization (what are you allowed to do?) are two of the most important security properties in apps, especially because the vast majority of Shopify apps serve multiple stores. Misimplementation of these concepts causes a great number of the security incidents we see.

This usually shows up when a request arrives carrying a shop parameter, a customer ID, an order ID, or a GraphQL global ID, and your code trusts those values to decide what it fetches or executes. The value looks structured and legitimate, so it gets trusted. But every one of those values is text in a request that the sender controls, and changing them is trivial.

Never derive identity from something the caller sent you. Instead:

  1. Authenticate the request using a Shopify session token or a validated signature.
  2. Derive the shop from that authenticated identity, not from the request.
  3. Confirm that the requested resource belongs to that shop.
  4. Confirm that the caller has permission to perform this specific action.

Apply all four steps everywhere, not just on your internet-facing API. Background jobs, webhook handlers, CSV exports, file downloads, GraphQL resolvers, and internal admin tools often touch the same data, and attackers are good at finding the code paths you forgot to protect.

Anchor to Authentication, OAuth, and webhooksAuthentication, OAuth, and webhooks

Authentication is a solved problem, and hand-rolled implementations are where it most often goes wrong. Use Shopify's official app templates and libraries wherever you can. They handle checks that are easy to get subtly wrong, and subtly wrong is difficult to distinguish from correct until someone attacks it.

If you're implementing any of this yourself, then at minimum:

  • Validate request HMACs before you trust any request parameter.
  • Validate every part of the session token: signature, expiry, audience, and destination claims.
  • Bind the OAuth state value to the browser that started the flow, and check it on return.
  • Require exact matches against your approved redirect URLs. No prefix matching, no wildcards.
  • Verify webhook HMACs against the raw request body before parsing.
  • Treat webhook headers, including the topic and shop domain, as untrusted until the signature verifies.
  • Make webhook handling idempotent, and use delivery IDs to detect replays.

For details, see authentication and authorization and verify webhook deliveries.

Anchor to Cross-site scriptingCross-site scripting

Cross-site scripting, commonly referred to as "XSS", happens when attacker-controlled content gets treated as trusted. The browser can't tell the difference between code you wrote and a malicious injection that your app or theme unknowingly renders.

The injected content usually arrives through a field nobody thinks of as dangerous:

  • Product titles.
  • Product descriptions.
  • Variant values.
  • Metafields.
  • Cart line item properties.
  • Discount codes.
  • Search queries.
  • Uploaded file names.
  • Any theme or app setting that you don't explicitly control.

Any of those can be rendered into a page later, and if it lands as markup, data, or code rather than as text, then it's a vector for attack.

Here's what attackers do with it:

  • Steal session cookies, access tokens, and other browser storage.
  • Exfiltrate personal information, order history, and payment details entered on the page.
  • Perform actions as the victim, including as a logged-in merchant in the Shopify admin.
  • Rewrite the storefront to run payment scams, SEO spam, or malware distribution using the store's reputation.

Shopify serves a Content Security Policy (CSP) that limits what a browser loads, which reduces the blast radius of an XSS attack. This should be considered a backstop, not a hard security control for your code. The policy has to stay permissive enough for apps and themes to do their jobs, and it does nothing about markup that your own theme or app renders into the page.

Consider this example:

{%- assign cfg = app.metafields.my_app.config.value -%}
<script>{{ cfg.custom_js }}</script>
<div style="{{ cfg.custom_css }}"></div>

This might look like a convenient way to let a store owner customize things, but it becomes an attack vector when someone can smuggle malicious JavaScript into that metafield. Don't assume that metafields can be trusted because they come from a Shopify database. Attackers have compromised store owner accounts to reach dynamic data and configuration fields before, and they'll continue to target weak points in store owner security to do so. The example also sidesteps CSP entirely because the script isn't fetched from another domain at all. It's rendered into the trusted page itself. The custom_css value is a second sink in the same snippet, and it needs the same treatment.

Never execute as code a string supplied by a store owner, a user, or any other source outside your control. Configuration and customization have better answers: structured settings, a fixed set of options, or a sandboxed extension point.

Search your code for these calls and assignments. They're the most common ways untrusted input gets executed:

element.innerHTML = untrustedValue;
element.insertAdjacentHTML('beforeend', untrustedValue);
document.write(untrustedValue);
$(element).html(untrustedValue);

Use an API that can produce only text:

element.textContent = untrustedValue;

Liquid doesn't escape output for you. {{ product.title }} emits raw markup, so escape it explicitly, and escape it for the context it lands in:

{% comment %} HTML body or attribute {% endcomment %}
{{ product.title | escape }}

{% comment %} Inside a <script> tag {% endcomment %}
<script>
var title = {{ product.title | json }};
</script>

{% comment %} Inside a URL parameter {% endcomment %}
<a href="/search?q={{ query | url_encode }}">

The script case is the one most people miss. escape is the wrong filter inside <script>, because HTML-escaping doesn't stop a value from breaking out of a JavaScript string. Use json.

If your product genuinely needs to accept HTML, then use a well-maintained sanitizer with a strict allowlist of tags and attributes. Don't try to enumerate what's bad. Validate against what you know is good.

URLs need validation too. Allow only the schemes you actually need, which is normally just https:, and reject javascript:, unexpected data: URLs, and protocol-relative URLs. A link target is a code execution sink.

Anchor to Dangerous active contentDangerous active content

Content you think of as data can carry code, or be interpreted as code. SVG and XML are the usual culprits: an SVG is an XML document that can contain <script> elements, event handler attributes, and external entity references. Browsers happily execute all of it when the file is served as image/svg+xml and loaded directly. An uploaded file is untrusted input, the same as any text field.

When you accept uploads:

  • Validate size, extension, declared MIME type, and the actual file signature. They should all agree. Note that just this step alone is not sufficient. Attackers have many ways of bypassing file type detection.
  • Generate the stored filename yourself. Never use supplied filenames as identifiers. Store them as metadata instead.
  • Store uploads outside any directory that your application serves or executes from. Use hosted object storage.
  • Serve files with an explicit Content-Type and Content-Disposition, so that attackers can't chain a malicious image or file with a vulnerability in client code.
  • Re-encode images through a trusted processing pipeline rather than storing the original bytes. Re-encoding drops embedded payloads as a side effect of decoding and rewriting the pixels. Managed image services can do this for you.
  • Reject SVG, XML, and HTML uploads unless your product truly requires them. If it does, then sanitize them and serve them from a separate origin, so they can't reach your session cookies.

Anchor to Unsafe browser messagingUnsafe browser messaging

Embedded apps, theme editor previews, and checkout extensions talk across frames with postMessage. The same cross-boundary communication that makes this technique useful also makes it a target for attackers.

Validate both the origin and the sender, then validate the message itself:

window.addEventListener('message', (event) => {
if (event.origin !== EXPECTED_ORIGIN) return;
if (event.source !== window.parent) return;

// Validate the message shape and values before acting on it.
});

Things that look safe but aren't:

  • origin.includes('shopify.com'), which matches shopify.com.attacker.net.
  • origin.startsWith('https://shopify'), which matches https://shopify-lookalike.net.
  • Checking the message contents and inferring the sender from them.
  • Sending sensitive messages with postMessage(data, '*'), which delivers to whatever is currently in that frame.

Compare origins with === against an exact expected value. Anything fuzzier is a bypass waiting to be found.

Anchor to Secrets in themes and public codeSecrets in themes and public code

Anything you ship in a theme or app bundle is public, and attackers scrape and scan it. That includes any secret or sensitive value in your code, no matter how buried or obfuscated. Automated scanners continuously crawl published storefronts and public repositories looking for credential patterns, including secrets that have been removed but are still checked into earlier commits.

We often find secrets in Liquid templates, theme assets, JavaScript bundles, source maps, build-time environment variables inlined at compile time, HTML comments, and hidden form fields. Anything you put in those places should be harmless in the hands of an attacker.

If a secret is exposed, then revoke or rotate it immediately. Don't wait for confirmation that someone used it. Attackers often bide their time until they know how to use a secret for maximum profit.

Anchor to Sensitive-data overcollectionSensitive-data overcollection

The most reliable way to avoid leaking customer data is to not have it. Collect personal information only when you need it, and only with express consent.

Specifically, don't do any of the following:

  • Capture password or login fields under any circumstances.
  • Record whole DOM snapshots or full browser event payloads when a single field is all you need.
  • Touch checkout or payment data.
  • Keep data "just in case", or retain it after it's served its purpose.
  • Request Shopify access scopes broader than your app uses.

When you do store personal data, encrypt it, restrict access to the services and people that need it, log who reads it, set a retention limit, and confirm that your deletion path works. Keep production customer data out of development and staging environments entirely.

See protected customer data and privacy requirements for the rules that apply to your app.


Anchor to Operate secure infrastructureOperate secure infrastructure

Your production environment should expose one thing to the internet: a hardened reverse proxy like Nginx or Caddy, a cloud load balancer, or a managed application host. These are battle-tested and heavily audited, and they emit telemetry that most observability platforms already understand. Monitor your public ingress traffic for anomalous activity that might indicate a compromise. While some programming languages promise the ability to place your application server directly on the internet, it is still considered best practice to use a reverse proxy for defense in depth.

Everything else should be private and unreachable from the public internet. Application processes, databases, queues, caches, admin panels, metrics dashboards, staging environments, test clusters, and internal services all belong on an internal network. Between VPNs, private cloud networks, and managed mesh services, enforcing a hard network boundary has never been easier.

Serve static assets and theme bundles from object storage or a CDN rather than from the application host, and gate the deployment of those assets as carefully as you gate your code.

Beyond that:

  • Use TLS for all external traffic, and authenticated TLS between your own services. See encrypt with TLS.
  • Require database authentication, and give each service only the permissions it uses. Your web app probably doesn't need DROP TABLE.
  • Keep production, staging, and development fully separate. Sharing signing keys, OAuth clients, or secrets between them means that a compromise of the weakest one is a compromise of all of them.
  • Encrypt data at rest, including backups, and periodically test that a restore actually works.
  • Apply security updates promptly.

Anchor to Manage supply-chain riskManage supply-chain risk

A supply-chain attack targets the third parties you depend on and exploits your trust in them. It usually arrives as a compromised package, a GitHub Action, a hosted CDN script, or a managed service you use to run your business. These attacks are among the hardest to prevent and remediate, so the defense is mostly reducing surface area and staying ready to respond.

You can reduce supply-chain risk in the following ways:

  • Depend on fewer things. Every external dependency is a party you've given write access to your code.
  • Prefer packages with clear ownership, real maintenance activity, and a security policy.
  • Commit lockfiles, and enforce them in CI with a frozen-install flag.
  • Pin GitHub Actions to a tag, or better, to a full commit SHA.
  • Wait before you adopt brand-new versions. Malicious releases are usually caught and pulled within days. Shopify recommends waiting until new package versions are at least seven days old before you include them in your development environments and pipelines.
  • Watch for bad security smells: a change of maintainer, a rename, a newly added install script, or a sudden jump in transitive dependencies.
  • Require MFA on every package registry and source control account, and use scoped, short-lived publishing tokens.

If you load scripts from a CDN in your frontend code, then pin an exact version and use Subresource Integrity, so the browser rejects the file if its contents change:

<script
src="https://cdn.example.com/library@1.2.3/library.min.js"
integrity="sha384-BASE64_HASH_HERE"
crossorigin="anonymous"
></script>

Anchor to Build with AI safelyBuild with AI safely

LLMs are showing up in Shopify apps and in the workflows used to build them. They also create attack surface that most existing controls weren't designed to cover.

Prompt injection is when content the model reads gets interpreted as instructions to follow. It doesn't have to come from the person chatting with your app or agent. It can arrive indirectly through a product description, a support ticket, a customer note, a scraped webpage, a PDF, or any other document you feed into context. Anyone who can write into one of those can write into your prompt.

There's no reliable way to prevent this with wording. A system prompt telling the model to ignore malicious instructions is a request, not a control. Design as though the model will eventually do the worst thing its permissions allow, because sooner or later it will. We highly recommend thoroughly sandboxing agentic processes that are exposed to the public.

Anchor to Treat AI output as untrusted inputTreat AI output as untrusted input

Everything in the input-handling sections above applies to model output. A model can produce malformed HTML, injectable SQL, shell metacharacters, javascript: URLs, and completely invented facts, with the same fluency either way.

  • Never pass model output straight into eval, a shell, a database query, or an HTML sink.
  • Never let a model decide whether a request is authenticated or authorized. Your existing authorization checks must hold regardless of what the model concluded.
  • Give AI tools the narrowest possible permissions and the smallest possible data scope.
  • Require explicit human confirmation before anything destructive, financial, or externally visible.
  • Apply rate, cost, and output-length limits.
  • NEVER send Shopify or customer data to a model provider unless that use is disclosed, permitted, and approved.

Two more things apply when you use AI to build your app, rather than building AI into it:

  • Review generated code before you ship it, particularly around authorization and escaping. That's exactly where plausible-looking code hides its mistakes.
  • Verify that packages an assistant suggests actually exist and are the ones you meant. Models routinely invent package names, and attackers register the common hallucinations and wait.

Anchor to When something goes wrongWhen something goes wrong

Write your incident plan before you need it. Having to figure out what to do under pressure and stress is how small incidents become large ones.

If you suspect a compromise:

  1. Contain it. Stop the affected operation or isolate the service.
  2. Revoke exposed credentials, tokens, and sessions.
  3. Preserve logs and evidence before they roll off.
  4. Notify Shopify at security@shopify.com
  5. Determine which stores, users, and data were affected.
  6. Fix the root cause, not just the symptom you noticed.
  7. Meet your legal and contractual notification obligations.
  8. Write up what happened and what control would have caught it.

Don't wait for a complete picture before you contain an active incident, and don't wait for certainty before you tell us. Early notice gives Shopify and the affected merchants more time to protect buyers.


Was this page helpful?