Sooner or later, a Shopify store needs something a theme can’t do: save a record, call a third-party API with a secret key, or work something out that the customer mustn’t be able to tamper with. A theme is just templates and front-end JavaScript, so you need a server somewhere.
A Shopify App Proxy lets that server sit behind a path on the shop’s own domain. Pair it with Cloudflare Workers and you get a small, fast back end with no servers to look after. Here’s how the pieces fit together, and the details I’d want to know before building one.
What an App Proxy does
In your app’s settings you choose a prefix and subpath, such as /apps/quotes, and a proxy URL, such as https://quotes.example.workers.dev. From then on, a request to:
https://shop.example.com/apps/quotes/submit
is forwarded by Shopify to:
https://quotes.example.workers.dev/submit
with some extra query parameters added. That has three big advantages over calling your server directly from the theme:
- Same origin. The theme’s JavaScript calls a path on its own domain, so there’s no CORS to configure.
- The customer is identified. If they’re logged in, Shopify adds
logged_in_customer_id, so your server knows who’s asking without handling a login. - Every request is signed. Shopify adds a
signatureparameter, so you can prove the request came through the proxy.
One thing to know early: Shopify strips cookies from proxied requests. Don’t build anything that relies on your own session cookie.
Verifying the signature
This is the part to get right first, because a proxy endpoint that skips it is just a public API. Shopify signs the query string with your app’s client secret. To check it:
- Take every query parameter except
signature. - Format each one as
key=value. If a key appears more than once, join its values with commas. - Sort them by key and join them with no separator.
- HMAC-SHA256 the result with the client secret and compare the hex digest to
signature.
In a Worker, with the Web Crypto API:
async function verifyProxySignature(url, secret) {
const params = new URL(url).searchParams;
const signature = params.get('signature') ?? '';
const grouped = {};
for (const [key, value] of params) {
if (key === 'signature') continue;
(grouped[key] ??= []).push(value);
}
const message = Object.keys(grouped)
.sort()
.map((key) => `${key}=${grouped[key].join(',')}`)
.join('');
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const digest = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
return timingSafeEqual(hex, signature);
}
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
Two extra checks are worth adding. The timestamp parameter lets you reject requests older than a few minutes, so a captured URL can’t be replayed forever. And the client secret belongs in a Worker secret (wrangler secret put), never in the code.
A signature isn’t intent
A valid signature tells you Shopify forwarded the request. It doesn’t tell you the customer meant to send it. Another site could embed a form that posts to your shop’s proxy path, and the customer’s browser would send it through Shopify as normal.
For anything that changes data, add the usual protections: check the Origin header against the shop’s domain, and rate-limit by customer or IP. For sensitive steps, such as a customer confirming a declaration, have the server issue a single-use token when the form is shown and require it on submit. If the token is missing, the form was never really displayed.
Structuring the Worker
I keep the entry point thin: verify, check origin, rate-limit, then route.
export default {
async fetch(request, env) {
if (!(await verifyProxySignature(request.url, env.SHOPIFY_CLIENT_SECRET))) {
return new Response('Unauthorised', { status: 401 });
}
if (request.method !== 'GET' && !originIsShop(request, env)) {
return new Response('Forbidden', { status: 403 });
}
const { pathname } = new URL(request.url);
const route = routes[`${request.method} ${pathname}`];
return route ? route(request, env) : new Response('Not found', { status: 404 });
},
};
Each route lives in its own module with its own tests. Workers run the same JavaScript locally under Vitest or Node’s test runner, so the signature check, pricing and validation can all be tested without deploying anything.
Returning JSON or Liquid
Most of my proxy routes return JSON for the theme’s JavaScript to use. But if a response has Content-Type: application/liquid, Shopify renders it as Liquid inside the shop’s theme layout. That’s handy for a page such as “your quotes”, which then gets the store’s header, footer and styles for free, without building a separate front end.
Where the data lives
A Worker has no disk, so state goes somewhere else:
- Shop metafields for configuration the client edits in the Shopify admin, read through the Admin API and cached briefly.
- R2 for uploaded files, with short-lived signed URLs to read them back.
- Durable Objects or KV for small pieces of state such as rate-limit counters or short-lived offers.
- An external system (a database, Airtable, a CRM) for the records themselves, especially if staff work from them.
Deploying safely
Put wrangler deploy in CI, after the tests, on the main branch only. If the tests fail, the Worker doesn’t ship. It sounds obvious, but it’s easy to end up deploying from a laptop “just this once” and then forever.
An App Proxy plus a Worker is the lightest way I know to give a Shopify store real server-side logic. If your store needs it, that’s the Shopify custom app work I do.