Shopify App Proxy: Verify the Signature, Serve Liquid

A Shopify app proxy forwards requests from a path on the merchant’s storefront, such as /apps/reviews, to a URL on your app server. Shopify appends shop, path_prefix, timestamp, logged_in_customer_id and an HMAC signature to the query string. Verify that signature before trusting anything, and return application/liquid if you want the response rendered inside the theme.

That is the whole mechanism. The trouble is in four details. The signature format differs from the OAuth hmac. The customer ID proves less than it appears to. Cookies never reach your server. And the merchant can move your URL without telling you.

This post was checked against the app proxy guide, the authentication page, @shopify/shopify-api 15.0.0 and @shopify/shopify-app-react-router 3.0.1, as of October 2026.

When is a Shopify app proxy the right tool?

Use one when storefront code needs data or HTML from your app and you want the request to come from the shop’s own domain. A theme app block that loads loyalty points, a wishlist page at /apps/wishlist, a form that posts to your backend: all of these can go through the proxy instead of calling your server cross-origin.

Three things make it attractive. The request is same-origin from the browser’s point of view, so there is no CORS configuration to maintain. Shopify tells you which shop the request is for and signs that claim. And if you reply with Liquid, the page renders inside the merchant’s theme with their header, footer and styles.

It is the wrong tool for anything checkout-related (that is Checkout UI extensions and Functions territory), and for your embedded admin UI, which authenticates with session tokens instead. It is also not a general-purpose CDN in front of your app. Every request is a round trip from Shopify to your server, so slow endpoints make slow storefront pages.

How do you configure the proxy in shopify.app.toml?

Each app gets one proxy route. Declare it in the app configuration, add the write_app_proxy scope, and deploy:

[access_scopes]
scopes = "write_app_proxy"

[app_proxy]
url = "/proxy"
prefix = "apps"
subpath = "reviews"

With that config, https://<shop>/apps/reviews proxies to <your app URL>/proxy, and https://<shop>/apps/reviews/product/123 proxies to <your app URL>/proxy/product/123. The url can be relative, in which case the CLI prepends your application URL.

The docs set the limits. prefix must be one of a, apps, community or tools. subpath takes letters, numbers, underscores and hyphens, up to 30 characters, and cannot be admin, services, password or login.

The merchant can change the path

Merchants can edit the prefix and subpath per store under Settings, Apps, then the app’s details page and “Customize URL”. Shopify’s guide says the change applies immediately to that store, and that because merchants control those two values, changing prefix or subpath in your own config only affects new installs. Changing url applies everywhere at once.

So do not hard-code /apps/reviews into theme code and assume it holds. Your server can always read the current path from the signed path_prefix parameter; on the theme side, give the block a setting or have your server write the path somewhere the theme can read it.

How do you verify the app proxy signature?

The signature is a hex-encoded HMAC-SHA256 of the other query parameters, keyed with your app’s client secret. The format differs from the OAuth callback hmac in two ways that matter if you adapt existing HMAC code: parameters are joined with no separator at all (no &), and a repeated key is collapsed into one entry with its values comma-separated.

The documented recipe is: remove signature, turn each remaining key into key=value with multi-values joined by commas, sort, concatenate, HMAC, compare. Shopify’s own example uses the secret hush and this query string:

extra=1&extra=2&shop=shop-name.myshopify.com&logged_in_customer_id=1&path_prefix=%2Fapps%2Fawesome_reviews&timestamp=1317327555&signature=4c68c8624d737112c91818c11017d24d334b524cb5c2b8ba08daa056f7395ddb

The string that gets signed is extra=1,2logged_in_customer_id=1path_prefix=/apps/awesome_reviewsshop=shop-name.myshopify.comtimestamp=1317327555. Note the decoded slashes in path_prefix: the values are signed unencoded.

Here is a framework-free version for Node 22 or later. It works on anything that gives you a URL:

import { createHmac, timingSafeEqual } from "node:crypto";

const MAX_SKEW_SECONDS = 90;
const SINGLE_VALUE = new Set(["shop", "signature", "timestamp"]);

export function verifyAppProxy(
  url: URL,
  secret: string,
  now = Math.floor(Date.now() / 1000),
): boolean {
  const params = new Map<string, string[]>();
  for (const [key, value] of url.searchParams) {
    const values = params.get(key) ?? [];
    if (values.length > 0 && SINGLE_VALUE.has(key)) return false;
    values.push(value);
    params.set(key, values);
  }

  const signature = params.get("signature")?.[0];
  const timestamp = Number(params.get("timestamp")?.[0]);
  if (!signature || !Number.isInteger(timestamp)) return false;
  if (Math.abs(now - timestamp) > MAX_SKEW_SECONDS) return false;

  params.delete("signature");
  const message = [...params]
    .map(([key, values]) => `${key}=${values.join(",")}`)
    .sort()
    .join("");

  const expected = createHmac("sha256", secret).update(message).digest();
  const given = Buffer.from(signature, "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

Called with Shopify’s example URL, secret hush and now set to 1317327555, it returns true. Change the secret, or move now ten minutes on, and it returns false.

Two checks in there go beyond the docs page, and both come from the official library. @shopify/shopify-api rejects the request if timestamp is more than 90 seconds from the server clock, which stops a captured URL being replayed indefinitely. It also refuses a request where shop, signature or timestamp appears more than once, because collapsing shop=a&shop=b into shop=a,b would sign a value that no single parameter holds.

What does logged_in_customer_id actually prove?

Less than the name suggests. The signature proves Shopify produced the query string and nobody edited it. The docs say this “only guarantees that the request hasn’t been tampered with”, and that your app must check that logged_in_customer_id matches the customer associated with the data being requested.

In practice: when a request asks for a customer’s wishlist, load the wishlist by the signed logged_in_customer_id, never by an ID the browser put in the path or a form field. For an anonymous visitor the parameter is present but empty, and it is still part of the signed string, so keep it in the message even when blank.

You also cannot fall back on your own session. Shopify strips Cookie from the request and Set-Cookie from the response, along with Date, Server, X-Powered-By and fourteen others listed in the guide. Anything stateful has to hang off the signed shop and customer ID, or off data you store server-side.

How do you return Liquid from an app proxy?

Send Content-Type: application/liquid and Shopify renders the body as Liquid in the context of the shop, using the shop’s theme. Any other content type is passed straight to the browser, which is what you want for JSON. Shopify follows 30x redirects from your server.

If you built on Shopify’s React Router app template, authenticate.public.appProxy does the verification for you and hands back a liquid helper:

import type { LoaderFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { liquid, session } = await authenticate.public.appProxy(request);

  if (!session) {
    return liquid("<p>This app is not installed on {{ shop.name }}.</p>");
  }

  const customerId = new URL(request.url).searchParams.get("logged_in_customer_id");

  return liquid(
    customerId
      ? "<h1>Your wishlist</h1><p>Signed in to {{ shop.name }}.</p>"
      : "<h1>Wishlist</h1><p>Log in to see saved products.</p>",
    { layout: true },
  );
};

The route file maps to whatever you set as url in the TOML. The 3.0.1 source shows how it behaves at the edges. An invalid signature throws a 400 Bad Request response rather than returning a flag. With no stored offline session for the shop, admin, storefront and session come back undefined while liquid still works. Passing { layout: false } prepends {% layout none %} so the theme chrome is dropped. The helper also rewrites relative href and form action paths to add a trailing slash.

Recommendation

Use an app proxy when a storefront feature needs server data and you want it on the merchant’s domain without CORS. On the React Router template, call authenticate.public.appProxy at the top of every proxy route and do not write your own. On any other stack, port the function above, keep the 90-second window and the duplicate-parameter check, and key every customer lookup off the signed logged_in_customer_id.

Skip the proxy when a theme app extension can do the job with metafields and no server call, or when latency matters more than same-origin convenience. A proxy adds your server’s response time to a storefront request, and Liquid rendering happens after that.

Whoooop builds Shopify apps and theme integrations, app proxy routes included. Our Shopify development page covers the kind of work we take on.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch