Two Shopify Apps on One Backend: Separate the Identity, Not the Business Logic
Two Shopify Apps can open into the same admin UI and reuse the same business services and database. What actually has to be separated is not the page code but each app's security boundary with Shopify.
If the backend stores state keyed only by shop domain, installing the second app on the same store can read the first app's access token, overwrite its session, or verify a webhook with the wrong secret. The rule for a shared backend is simple: business data may be shared per store, platform identity must be separated per app.
Three identities to keep apart
| Identity | Question it answers | Typical source |
|---|---|---|
| App identity | Which Shopify App this request belongs to | A fixed entry point, deployment config, or an already-validated token |
| Store identity | Which store this operation belongs to | The shop in a validated request, or the store record in the database |
| Installation generation | Which installation of that app | An installation record, session version, or equivalent lifecycle marker |
App identity must never come from a browser-supplied parameter such as app=public. Let a fixed path or a separate deployment establish the app first, then validate the request with that app's credentials.
Public App ── /shopify/public/* ──┐
├─ shared business service, admin UI, database
Custom App ── /shopify/custom/* ──┘
Installation generation cannot be skipped either. After an uninstall and reinstall, a late refresh request or webhook may still belong to the previous installation. An appKey alone cannot tell you whether that event may still modify the current record.
Where the app has to be distinguished
Authorization entry and callback
Each app has its own client ID and secret. With the authorization code grant, the entry point establishes the app before building the authorization URL; the callback path carries that trusted context forward, and the matching secret is used to verify the HMAC and exchange the code for a token. An embedded app using Shopify-managed installation should instead establish app and store through the App Bridge ID token and token exchange, following the current template — the two flows must not be merged into one. An ID token authenticates the user, expires one minute after it is issued, and must be validated by the backend and exchanged for an access token; it cannot be used to call the Admin API. See ID tokens.
Two apps can share a domain, but separate paths are better:
/shopify/public/auth/callback
/shopify/custom/auth/callback
The server then knows which configuration to load before it parses a single callback parameter. How application_url, redirect_urls, and callback validation relate is covered in Shopify OAuth Callbacks.
Reading, writing, deleting, and refreshing sessions
A session cannot be keyed by offline_<shop> alone. At minimum the app identity belongs in the storage boundary — (appKey, sessionId), say — and ownership should be re-checked on read.
Checking the app only on write is not enough. While an old app's session is still valid, a read path can hit it first and bypass the write-side check. Deletion and refresh need the same constraint: a refresh may only update a record that still belongs to the current installation, and must not re-establish ownership after an uninstall.
The Admin API client
Business code should not check for public versus custom all over the place. A better interface has the business layer supply a store, and the platform layer looks up the valid installation and returns an Admin client already holding the right app credentials and token.
business action
→ find the valid installation for the store
→ establish app and installation generation
→ load the matching session
→ build an Admin API client
That keeps credential selection in a few modules instead of having sync jobs, uploads, background workers, and MCP endpoints each implement their own.
Webhooks and deletion events
A webhook has to be verified with the secret of the app that received it, and only then checked against the current installation. app/uninstalled should release that app's sessions and ownership, not wipe every piece of platform data for the store.
Privacy deletion needs its own design. Shopify sends shop/redact 48 hours after a store owner uninstalls the app (see Privacy law compliance). If another app has taken the store over within that window, the old app's deletion event must not clear data the new installation is still using.
That does not mean shared data is never deleted. The app still has to meet its data deletion obligations. The implementation needs to record data origin, retention basis, and installation stage, so that deletion neither damages a new installation nor uses the shared database as an excuse to skip compliance.
Billing
Sharing business features does not mean sharing a billing model. A public app might use Shopify App Pricing, while a custom-distribution app's entitlements come from a contract. The entitlement policy should be chosen from the installation record, not scattered as if public checks across business endpoints.
A store claim model
If the product rule is "a store may use only one of these apps at a time", store the current claim:
ShopInstallation
- shopId
- appKey
- installationGeneration
- status
- installedAt
- uninstalledAt
Establishing a claim means comparing two things: the existing ownership in the database, and the app identity that has already been validated for this request. On a first install the claimant cannot be read from the database, because there is no record yet; it has to come from the authorization that is currently in progress.
If a store is allowed to have both apps installed at once, drop the single claim field and make (appKey, shopId) the installation boundary. Which model applies is a product rule, not something session behavior should settle by accident.
Migration and rollback
Turning a single-app system into a multi-app one is most dangerous while old code and the new data model are both writing. A single up-front backfill cannot cover installs and uninstalls that happen between the backfill and the cutover.
A release order that holds up usually looks like this:
- Ship a version that reads and writes the new ownership fields while staying compatible with old data.
- Backfill existing installations and list the records whose origin cannot be determined.
- Stop the old writer and do a final reconciliation.
- Only then open the second app's installation entry point.
- Verify install, refresh, uninstall, late webhooks, and reinstall.
Once the second app has its first valid installation, rolling back to a version that does not understand app isolation is generally no longer safe. From that point a rollback target must retain identity separation, or be preceded by an explicit data recovery and store migration.
Acceptance scenarios
- With app A installed on a store, app B's authorization is either refused or creates an independent installation, per the product rule.
- App A's valid session cannot be read by a request for app B.
- A token refresh for app A interleaved with an uninstall does not restore ownership after the uninstall.
- After app A is uninstalled and app B takes over, a late webhook for A does not delete B's session.
- Every webhook is verified with the secret of its own app.
- Background jobs reach Shopify only through the single "Admin client by store" entry point.
- Installs and uninstalls that occur during the cutover all reach the final reconciliation.
The goal of a shared backend is not to make two apps look like one. It is to let them share one set of business capabilities inside their own security boundaries.