Guides

Developer guide

Shopify app proxy guide for Rails backends and React frontends

A practical guide to using Shopify app proxies with a Rails backend and React frontend, including request validation, frontend architecture, and the cases where proxies are the wrong tool.

Updated March 12, 2026
16 min read
Editorial note: Most app proxy bugs are boundary bugs. Teams use a storefront delivery path to solve an admin or extension authentication problem, and then act surprised when the architecture bites them.

What app proxies are actually for

A Shopify app proxy gives your app a storefront-facing URL under the shop domain and forwards that request to your app server. Shopify documents app proxies as a way to fetch and display dynamic storefront data from an external source, and it also notes that a Shopify app gets only one proxy root. Everything under that root is yours to route internally.

“A Shopify app can have only one proxy route configured.”

That single sentence matters more than it looks. It means you should treat the proxy as a small storefront surface area with intentional subroutes, not as a random pile of ad hoc endpoints you discovered one panic deploy at a time.

Good app proxy use cases are storefront-scoped:

  • theme widgets that need app-managed data at render time
  • AJAX endpoints for a storefront component
  • light personalization based on shop or signed customer context
  • gated content where your backend owns the entitlement logic
  • Liquid responses that should render in the context of the merchant’s theme

Bad app proxy use cases are the ones where someone says, “Couldn’t we just run it through the proxy?” in the tone of a person about to create a six-month cleanup project.

App proxies do not replace embedded app authentication. Shopify’s session token docs are explicit that session tokens are how an embedded app authenticates client-to-backend requests. They are also explicit that session tokens are for authentication, not authorization.

“Session tokens are for authentication, and aren't a replacement for authorization.”

The working rule

If the request exists because a buyer loaded the storefront, an app proxy might be the right delivery path. If the request exists because a merchant opened your embedded admin app, use session tokens instead.

The Rails plus React architecture to standardize on

The cleanest architecture is boring in the best way:

  • Shopify owns the storefront URL and forwards requests.
  • Rails owns request validation, shop resolution, authorization, and data access.
  • React owns only the interactive surface that actually benefits from client-side state.

That usually means one of three shapes:

ShapeWhen it fitsWhy it works wellMain caution
Server-rendered HTMLSimple widgets, read-only blocks, lightweight personalizationFast, easy to debug, few moving partsDo not overcomplicate it with a frontend build step
Liquid responseWhen theme context or store data should render in Shopify’s Liquid layerFeels native to the storefrontKeep the returned Liquid small and intentional
HTML plus React islandInteractive widgets with real client-side behaviorGood balance between UX and operational sanityDo not quietly turn it into a full storefront SPA

Full React SPAs behind an app proxy are possible. They are also a very effective way to earn yourself asset-loading edge cases, hydration weirdness, cache confusion, and the occasional “works on my tunnel” experience.

A thin React island is usually the sweet spot. Let Rails serve the initial shell, embed a tiny bootstrap payload, and let React handle only the interactions that actually need JavaScript.

// storefront widget bootstrap
document.querySelectorAll("[data-support-widget]").forEach((node) => {
  const bootstrap = JSON.parse(node.getAttribute("data-support-widget") || "{}");
 
  import("./support-widget").then(({ mountSupportWidget }) => {
    mountSupportWidget(node, bootstrap);
  });
});

If you need product, collection, search, or cart data, stop and ask whether the Storefront API already solves the problem directly. Shopify’s Storefront API supports tokenless access for products, collections, selling plans, search, pages, blogs, articles, and cart operations. A proxy is not automatically the best answer just because your backend already exists.

“Tokenless access allows API queries without an access token”

The request flow from storefront to Rails and back

Shopify’s documented forwarding behavior is the key mental model. A storefront request to the proxied shop URL gets forwarded to your app URL, and Shopify adds query parameters such as shop, logged_in_customer_id, path_prefix, timestamp, and signature. It also forwards the request method and body, and includes X-Forwarded-Host and X-Forwarded-For.

In practice, a healthy Rails flow looks like this:

  1. A theme script, app block, or storefront page requests a proxied path such as /apps/instasupport/support-box.

  2. Shopify forwards that request to your Rails proxy root and appends signed proxy parameters.

  3. Rails verifies the signature before touching business logic.

  4. Rails resolves the shop, optionally uses logged_in_customer_id as a signed hint, and then runs its own authorization rules.

  5. Rails returns one of: HTML, JSON, or application/liquid.

  6. A small React island hydrates only if the widget truly needs interactivity.

The corresponding app config is small:

[access_scopes]
scopes = "write_app_proxy"
 
[app_proxy]
url = "/app_proxy"
prefix = "apps"
subpath = "instasupport"

Shopify recommends file-based configuration so the proxy setup lives in source control and deploys through Shopify CLI. That is the adult answer. The other answer is “I changed something in the dashboard last Tuesday and now nobody remembers what.”

Because an app only gets one proxy root, your Rails router should make that explicit:

# config/routes.rb
scope :app_proxy do
  get  "support-box",        to: "app_proxy/support_boxes#show"
  get  "support-box.json",   to: "app_proxy/support_boxes#data"
  post "eligibility",        to: "app_proxy/eligibility#create"
  get  "*path",              to: "app_proxy/fallback#show"
end

One subtle detail matters a lot in production. Shopify says users can customize the proxy prefix and subpath in admin, and those storefront-facing values can differ from what your original app config says. That means your code should not assume the storefront path is permanently identical to your shopify.app.toml values.

How to validate proxy traffic in Rails

This is the part where teams either build a reliable system or accidentally invent a data leak.

Shopify’s proxy auth docs say to compute an HMAC-SHA256 digest over the forwarded query parameters, excluding signature, and compare it to the supplied signature. Their Ruby example uses request.query_string in Rails, parses the query, removes signature, normalizes array values, sorts the params, concatenates them, and compares with a constant-time comparison.

“The signature is unencoded, sorted, concatenated”

Put that logic in a reusable concern or middleware. Do not copy-paste it into three controllers and call it architecture.

# app/controllers/concerns/verify_shopify_app_proxy.rb
module VerifyShopifyAppProxy
  extend ActiveSupport::Concern
 
  included do
    before_action :verify_shopify_app_proxy!
  end
 
  private
 
  def verify_shopify_app_proxy!
    parsed_query = Rack::Utils.parse_query(request.query_string.to_s)
    provided_signature = parsed_query.delete("signature").to_s
 
    raise ActionController::BadRequest, "Missing proxy signature" if provided_signature.blank?
 
    normalized = parsed_query
      .map { |key, value| "#{key}=#{Array(value).join(",")}" }
      .sort
      .join
 
    expected_signature = OpenSSL::HMAC.hexdigest(
      "SHA256",
      ENV.fetch("SHOPIFY_API_SECRET"),
      normalized
    )
 
    unless ActiveSupport::SecurityUtils.secure_compare(provided_signature, expected_signature)
      raise ActionController::BadRequest, "Invalid proxy signature"
    end
 
    @proxy_shop_domain = parsed_query["shop"].to_s
    @proxy_customer_id = parsed_query["logged_in_customer_id"].presence
    @proxy_path_prefix = parsed_query["path_prefix"].to_s
    @proxy_timestamp = parsed_query["timestamp"].to_s
  end
end

Then use it from a base controller:

# app/controllers/app_proxy/base_controller.rb
class AppProxy::BaseController < ActionController::Base
  include VerifyShopifyAppProxy
 
  protect_from_forgery with: :null_session
 
  private
 
  def current_shop!
    @current_shop ||= Shop.find_by!(shopify_domain: @proxy_shop_domain)
  end
end

The signature check proves Shopify forwarded the request. It does not prove the requester is allowed to read or mutate the data you are about to expose. Shopify’s own docs call this out for logged_in_customer_id. You still need your own ownership and entitlement checks.

# app/controllers/app_proxy/support_boxes_controller.rb
class AppProxy::SupportBoxesController < AppProxy::BaseController
  def show
    shop = current_shop!
 
    inbox = shop.support_inboxes.find_by!(public_token: params[:token])
 
    if inbox.customer_locked?
      unless @proxy_customer_id.present? && inbox.customer_shopify_id.to_s == @proxy_customer_id
        return head :forbidden
      end
    end
 
    @bootstrap = {
      shop: shop.shopify_domain,
      inboxId: inbox.id,
      customerId: @proxy_customer_id,
      endpoint: "#{@proxy_path_prefix}/support-box.json?token=#{CGI.escape(inbox.public_token)}",
    }
 
    render
  end
end

A few implementation rules are worth standardizing:

  • fail closed if the signature is missing or invalid
  • treat shop as signed context, then resolve your local shop record
  • treat logged_in_customer_id as a signed identifier, not as automatic authorization

  • avoid hardcoding the current storefront path because merchants can customize the proxy prefix and subpath

  • design the route so all privileged logic still depends on your own database rules

Shopify also warns that new proxy parameters can be added over time. Build the verification from the full query string instead of hand-picking a frozen list and hoping the platform never evolves, which is historically not how platforms work.

What to return from a proxy route

Shopify documents three behaviors that matter here:

  • if the proxy response is Content-Type: application/liquid, Shopify renders Liquid in the shop context

  • otherwise the response is returned directly to the client
  • 30x redirects are followed

That gives you a useful menu of patterns.

1. Plain HTML for a thin widget

This is the default winner for small storefront features.

class AppProxy::SupportBoxesController < AppProxy::BaseController
  def show
    shop = current_shop!
    inbox = shop.support_inboxes.find_by!(public_token: params[:token])
 
    @bootstrap = {
      token: inbox.public_token,
      customerId: @proxy_customer_id,
      jsonEndpoint: "#{@proxy_path_prefix}/support-box.json?token=#{CGI.escape(inbox.public_token)}"
    }
 
    render :show
  end
 
  def data
    shop = current_shop!
    inbox = shop.support_inboxes.find_by!(public_token: params[:token])
 
    render json: {
      title: inbox.title,
      status: inbox.status_label,
      customerId: @proxy_customer_id
    }
  end
end
<%# app/views/app_proxy/support_boxes/show.html.erb %>
<div
  data-support-widget="<%= json_escape(@bootstrap.to_json) %>"
>
  Loading support widget...
</div>

2. Liquid when the shop context should do part of the rendering

This is useful when you want the response to feel like part of the theme, not just some foreign object stapled onto it at 2 AM.

class AppProxy::AnnouncementsController < AppProxy::BaseController
  def show
    render plain: <<~LIQUID, content_type: "application/liquid"
      <section class="app-announcement">
        <h2>Support from {{ shop.name }}</h2>
        <p>Questions about your order? We can help.</p>
      </section>
    LIQUID
  end
end

3. JSON for AJAX or React islands

JSON is the cleanest shape when the storefront already owns the DOM and only needs data. Keep the payload narrow. Do not return your entire admin-side object model just because render json: record was one line shorter.

async function loadSupportBox(token) {
  const response = await fetch(`/apps/instasupport/support-box.json?token=${encodeURIComponent(token)}`);
 
  if (!response.ok) {
    throw new Error(`Proxy request failed with ${response.status}`);
  }
 
  return response.json();
}

4. Know the cookie trap before it knows you

Shopify strips the Cookie header from proxy requests and strips Set-Cookie from proxy responses. So if your clever plan is “we’ll just keep a normal Rails session on the proxy route,” congratulations, the platform has already vetoed you.

“App proxies don't support cookies”

That one constraint alone is why proxy routes should be treated as signed storefront requests, not as your usual session-backed web app.

Where app proxies are the wrong tool

The easiest way to misuse an app proxy is to ask it to solve a different category of problem.

1. Embedded admin authentication

For embedded admin apps, use session tokens. Shopify says the frontend should acquire a session token from App Bridge and include it in the authorization header for backend requests. The backend authenticates those requests with the token. That is the official path.

// conceptual admin-side pattern
const token = await getSessionTokenSomehow();
await fetch("/api/admin/support-rules", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

An app proxy is not a substitute for that. It is a storefront route. Different surface, different trust model, different job.

2. Customer account UI extensions as a customer identity shortcut

Shopify’s customer account UI extension docs are blunt here. App proxy requests from UI extensions execute as CORS requests, they do not assign logged_in_customer_id, and password-protected shop proxy requests from those extensions are unsupported. Use a session token and validate the token’s claims on your server instead.

“UI extension requests made to the App Proxy will not assign the logged_in_customer_id query parameter.”

If you do direct network requests from a customer account extension, Shopify also requires CORS support for null origins and says your server should return Access-Control-Allow-Origin: *. Again, different surface, different constraints.

3. Data that Shopify already exposes cleanly through the Storefront API

If your storefront code just needs products, collections, search, articles, or cart operations, the Storefront API often gives you a cleaner boundary. Do not proxy everything through Rails because “it feels safer.” Sometimes it just means you built a private bottleneck in front of a public platform API.

4. High-privilege writes with weak business checks

The proxy signature proves that Shopify forwarded the request, not that the action is low-risk. If you expose a destructive or sensitive action behind a proxy route, your own app still has to decide whether the request is allowed. Signed transport is not magical authorization glitter.

Common production failure modes

These are the mistakes that show up again and again:

  • Trusting params before signature verification. Do not look up a shop, customer, or record first and validate second.

  • Assuming the proxy path is static forever. Merchants can customize prefix and subpath.

  • Building the proxy like a cookie-backed app. Shopify strips cookies. That session-based plan is already dead.

  • Using a proxy for embedded admin traffic. Use session tokens.

  • Returning too much data. Proxy JSON should be storefront-safe, not a serialized admin object dump.

  • Turning every widget into a frontend framework event. React islands are useful. Full SPAs behind a proxy are usually overkill.

  • Forgetting that request bodies are forwarded too. POST endpoints still need validation and authorization, not just signature checks.

  • Ignoring extension-specific rules. Customer account UI extensions and storefront theme requests do not have the same trust model.

A production checklist that actually helps:

# pseudo-checklist, not framework magic
#
# 1. Verify HMAC signature from request.query_string
# 2. Resolve shop from signed `shop`
# 3. Enforce your own authorization rules
# 4. Return only storefront-safe fields
# 5. Avoid cookie/session assumptions
# 6. Keep the initial response small
# 7. Log invalid signatures and denied access separately
# 8. Treat proxy routes as public attack surface with signed context

The architecture smell

If your proxy controller starts needing merchant identity, Admin API user permissions, long-lived browser session state, and a React router tree big enough to have its own climate, you do not have an app proxy problem. You have chosen the wrong surface.

Best internal links

Sources and further reading

FAQ

Should I use an app proxy instead of session tokens for my embedded admin app?

No. App proxies are for storefront-originated requests. Embedded admin requests should use session tokens from App Bridge and backend validation on your server.

Can I trust `logged_in_customer_id` after the proxy signature passes?

You can trust that Shopify forwarded it, but you still need your own authorization checks. Signature validation proves transport integrity, not entitlement to the data you are about to return.

Can I use cookies or a normal Rails session on app proxy routes?

Not as a core auth mechanism. Shopify strips the `Cookie` header from the proxy request and strips `Set-Cookie` from the response.

Do customer account UI extensions get `logged_in_customer_id` on app proxy requests?

No. For customer account UI extensions, Shopify says app proxy requests do not assign `logged_in_customer_id`. Use a session token and validate its claims server-side instead.

Related resources

Keep exploring the playbook

Guides

Shopify session tokens in Rails

A Rails-focused guide to Shopify session tokens for embedded apps, covering the Shopify App gem, token exchange, frontend request flow, and the mistakes that still break production auth.

guidesShopify developersession tokens