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.
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:
| Shape | When it fits | Why it works well | Main caution |
|---|---|---|---|
| Server-rendered HTML | Simple widgets, read-only blocks, lightweight personalization | Fast, easy to debug, few moving parts | Do not overcomplicate it with a frontend build step |
| Liquid response | When theme context or store data should render in Shopify’s Liquid layer | Feels native to the storefront | Keep the returned Liquid small and intentional |
| HTML plus React island | Interactive widgets with real client-side behavior | Good balance between UX and operational sanity | Do 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:
A theme script, app block, or storefront page requests a proxied path such as
/apps/instasupport/support-box.Shopify forwards that request to your Rails proxy root and appends signed proxy parameters.
Rails verifies the signature before touching business logic.
Rails resolves the shop, optionally uses
logged_in_customer_idas a signed hint, and then runs its own authorization rules.Rails returns one of: HTML, JSON, or
application/liquid.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"
endOne 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
endThen 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
endThe 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
endA few implementation rules are worth standardizing:
- fail closed if the signature is missing or invalid
- treat
shopas signed context, then resolve your local shop record treat
logged_in_customer_idas a signed identifier, not as automatic authorizationavoid 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
end3. 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_idquery 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 contextThe 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
Shopify session tokens in Rails
for embedded admin authentication and backend verification patterns.
React frontend plus Rails backend for Shopify embedded apps
for the admin-side architecture where proxies are usually the wrong boundary.
When app proxies do not work for Shopify UI extensions
for checkout and customer account extension caveats.
Sources and further reading
Shopify Dev: About app proxies and dynamic data
Shopify Dev: Authenticate app proxies
Shopify Dev: About session tokens
Shopify Dev: Exchange a session token for an access token
Shopify Dev: Customer account UI extensions configuration
Shopify Dev: Storefront API
Shopify Dev: React Router app proxy authentication helper
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
React frontend plus Rails backend for Shopify embedded apps
How to structure a Shopify embedded app with React on the frontend and Rails on the backend, including auth boundaries, deployment shape, and the operational tradeoffs that matter in production.
Shopify OAuth and managed install in Rails apps
A guide to the current Shopify installation model for Rails apps, explaining where managed install replaces older OAuth assumptions and where authorization code grant still matters.
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.