The cheapest security control in software is the access you never ask for. Nobody can breach what you don't hold, nobody can subpoena what you never stored, and no code path can misuse a permission that doesn't exist. Everyone nods at this. Almost nobody prices it — because the cost of least privilege isn't paid in an incident report, it's paid in capabilities: every scope you refuse is a feature you now have to build another way, or not at all. This essay is that price list, from a portfolio where the receipts are checkable: our seven Shopify apps, whose permission manifests were re-parsed from the config files the morning this published.
The Ledger
Every Shopify app declares its access scopes in a config file the platform enforces. Across all seven of ours: 18 required scope slots, 12 distinct scopes, one optional scope — and a filter for order or customer across every one of them returns nothing. Not a reduced set of order permissions. None.
| App | Required scopes | What they are |
|---|---|---|
| Cited | 1 | read_products |
| Old Glory | 2 | read_products, read_content |
| CheckoutProof | 2 | read_script_tags, read_checkout_branding_settings |
| AgeProof | 2 (+1 optional) | read_validations, write_validations (+ optional write_products) |
| Merchant Guard | 3 | read_products, read_content, read_legal_policies |
| AccessGuard | 4 | read_products, write_files, read_content, write_content |
| Payout Guard | 4 | read_products, read_privacy_settings, read_shopify_payments_disputes, read_shopify_payments_payouts |
The platform has a name for the bottom rung this puts us on. Shopify's protected-customer-data framework defines its subject as “any data that directly relates to a customer or prospective customer, as represented in the API types and resources,” and classifies apps that use none of it as Level 0: “No customer data” — “No action required.” That's the entire compliance surface for that framework: a row in a table that asks nothing of us. (One precision note, because this article's title was chosen carefully: that page names no access scopes at all, so “Level 0” is Shopify's classification and “no order or customer scope” is our own computed claim. They point the same direction; they are not the same sentence.)
Where the Boundary Is Thinner Than It Sounds
Before the virtuous part, the honest part. A scope list like ours does not mean order data is unreachable — and one of our own scopes proves it. Shopify's ShopifyPaymentsDispute object, queryable under the read_shopify_payments_disputes scope Payout Guard holds, carries an order field: “The order that contains the charge that's under dispute.” A dispute is, after all, about a purchase somebody made. So the accurate claim is not “order data cannot flow to us” — it's that our queries never select it: Payout Guard's dispute queries pull initiatedAt and type transactionDate, nothing else — no amounts, no order, no buyer fields — and its refund-rate figure is a count ratio over transaction types, not a money number. Least privilege turns out to have two layers: the scopes you request, and the fields you select inside them. The scope list is enforced by the platform; the field discipline is enforced by nothing but the code, which is exactly why it belongs in print where a skeptic can go check it.
What Each Refusal Cost, and How the Capability Came Back
The theme-write refusal, and the feature that returned as an extension. AccessGuard fixes accessibility issues, and the obvious design — the one we actually built first — edits the merchant's theme code. That design needs theme scopes, and it also collides with an App Store rule Shopify states plainly: requirement 5.1.1, “Use theme app extensions” — “If your app modifies the merchant's theme, you need to use theme app extensions. You or merchants should not make any code changes to the theme.” So the capability came back as exactly that: an app embed that delivers the language, zoom, skip-link and focus repairs at runtime, with the theme's code never edited — switch the embed off and they're gone. The revealing artifact is what's left behind: the theme-editing planner still exists in the engine, endpoint and tests included, and the app shell simply never calls it. Its own comment records the decision — the endpoint is “retained for a possible future 'show the exact code to paste' guided feature, but the shell no longer writes themes.” Least privilege here wasn't “never build it.” It was building it, then leaving it unreachable because the unprivileged design turned out to be the shippable one.
The write-scope you can delete by choosing a different owner. Cited generates FAQ content and writes it to the store — a write feature on a one-scope app. The trick is a platform rule about metafields: per Shopify's docs, setting a metafield “requires the same access level needed to mutate the owner resource. For instance, if you want to set a metafield on a product, you need the same permissions as you would need to mutate a product.” Read that rule from the other side and it's an offer: hang the metafield on something you already own and the write scope evaporates. Cited writes its output to a metafield owned by the app's own installation, so the whole feature ships on read_products alone.
The same rule, paid in the other direction — on purpose. AgeProof's per-product gating flags live on product metafields, and by the rule above, flagging a product requires product write access. We could have required write_products from everyone. Instead it's the portfolio's only optional scope, and the rationale sits in the code where the decision lives: only per-product gating needs it, per-product gating is a paid feature, and requiring it up front “would show every free merchant a 'manage your products' prompt for something they cannot use.” A merchant who declines isn't stranded, because the decline path is real code, not a shrug — the gating list travels in the app's own configuration instead, same enforcement, less headroom. (How the checkout gate itself works is Thursday's story, not this one.)
The scope that's honest about seeing almost nothing. CheckoutProof inventories checkout scripts ahead of Shopify's checkout.liquid shutoff, and it holds read_script_tags — a scope with a boundary Shopify documents on the REST ScriptTag resource page: “Script tags are scoped to the app that created them,” and when an app is uninstalled, its script tags are “automatically removed along with it.” No app can enumerate another app's scripts — which is why the Other Shelf essay already put the blunt version in print: no app can see every script on your store, and claiming otherwise would be a lie of scope. What the scope ledger adds is how the gap gets covered without new permissions: a rendered-storefront crawl that reads what the browser actually loads — guarded by a real SSRF allowlist that resolves and pins IPs and rejects private, loopback and link-local addresses — plus a manual checklist for what no automation can reach. The app says all of this to merchants in the product itself, under a heading that reads “Honesty first.”
And the zero-scope end of the spectrum. The logical conclusion of this whole ledger is an app with no scopes at all, and we ship one: the free public Merchant Center checker reads nothing but the public storefront — “We only read your public storefront — no login, and we never store your data,” per its own page — because for a subset of checks (policy pages, contact routes, homepage copy), the strongest possible privacy posture is simply being a stranger. That tier of the product needs no trust because it holds no access.
Where Minimisation Buys Nothing
One boundary of the argument, so this doesn't read as a sales pitch for zero scopes: some obligations attach to distribution, not to data. All seven apps declare the privacy-compliance webhooks, because Shopify's rule is unambiguous — “Any app that you distribute through the Shopify App Store must respond to data subject requests, regardless of whether the app collects personal data.” Level 0 spares you the protected-data checklist; it does not spare you the machinery for saying “we have nothing” on demand. Fine by us — an empty response is the easiest compliance artifact we produce.
The Promise as Actually Sold
Scope discipline only matters to a merchant if it surfaces where they decide to install, so here is how it reads on the product pages, quoted from the live pages this morning. Merchant Guard, Payout Guard and Old Glory each carry: “Read-only access; we never touch your customer data.” CheckoutProof carries the tighter variant its scopes earn: “Reads only store config and public store pages; we never touch your customer data.” And AccessGuard — which is not read-only, because applying alt text and publishing a statement page are writes — says exactly what it does instead: it “writes only image alt text and the statement page; it never reads or writes customer data.” Notice which clause survives on every page: not “read-only,” which is only true for the apps whose scopes make it true, but the customer-data sentence, which the ledger above makes true for all seven. That's the discipline this essay is about — the marketing claim is downstream of the config file, not the other way around.
One claim we deliberately don't make in public: that minimal scopes buy a faster App Store review. Our own config files contain that hope as an internal comment in three of the seven — and it stays internal, because Shopify publishes no such promise. The publishable facts are the ones above: Level 0, no action required, and nothing to breach. If the review queue happens to be kinder to apps that ask for nothing, that's a bonus we'll decline to count until the platform says it out loud.
The Actual Economics
So what did least privilege cost, totalled up? One feature rebuilt as a theme extension instead of a theme edit. One write feature redesigned around metafield ownership. One paid feature carrying an optional scope and a fallback path. One visibility gap closed with a crawler and a checklist instead of a permission. Weeks of engineering, spread over seven apps — against which we hold: no order data, no customer data, no breach surface for either, a one-row compliance posture, and seven product pages whose strongest privacy sentence is generated by a config file rather than a copywriter. Production-ready, on this shelf, includes “holds nothing it doesn't need.” And because product claims rot faster than code, every number in this essay — the 18, the 12, the 1, the zero — was recomputed from the seven config files on publication morning, and the boundary cases (the dispute object's order field, the built-but-unreachable theme planner) are stated rather than smoothed. Where our tools stop is a genre we publish in; this is the permissions chapter.
The One-Sentence Version
Least privilege is a real bill paid in redesigned features — a theme extension here, an app-owned metafield there, a crawler where a permission would have been — and what it buys is the only privacy claim that can't rot: there is nothing behind the door, because the door was never requested.
The Shelf Behind the Ledger
Seven marketplace apps, 12 distinct scopes between them, zero order or customer access — what each one does and refuses to promise.
The Other Shelf