Developer guide
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.
What the Shopify App gem already does for you
If you are building an embedded Shopify app in Rails, the default answer is not to start by hand-rolling JWT verification. It is to use the Shopify App gem.
Shopify’s session-token setup docs explicitly recommend the Shopify App gem for Rails
when decoding and verifying session tokens. The shopify_app README says
the default generator builds an app that can be embedded in the Shopify Admin and
secures it with session tokens. It also says the generated app implements OAuth 2.0
token exchange and is configured to use session tokens when embedded in the admin.
“We recommend using the Shopify App gem”
“The default Shopify App generator builds an app that can be embedded in the Shopify Admin and secures it with session tokens.”
That changes how this article should be framed. The main job is not teaching people how to rebuild the gem from scratch. The main job is teaching what the gem handles, what still belongs to your app, and when the internals become worth understanding.
The normal starting point for a Rails app is either the Shopify CLI Ruby template or
an existing Rails app with shopify_app installed:
shopify app init --template=ruby
# or in an existing Rails app
bundle add shopify_app
rails generate shopify_app
rails db:migrate
shopify app devThat path is intentionally boring. Good. Auth should be boring. The more “creative” your auth stack is, the more likely it is that future-you will be staring at a 401 in production while whispering, “but it worked in development”.
The practical rule
In Rails, manual JWT verification is the advanced section, not the starting point.
The embedded Rails request flow that actually ships
The real embedded flow is simpler than many articles make it sound:
- Shopify Admin loads your embedded app shell.
App Bridge authenticates requests from the frontend to your backend using a session token.
Your Rails backend accepts the request, trusts the verified token path handled by the gem or shared auth layer, and resolves shop and user context.
If your backend needs to call Shopify, it uses an online or offline Admin API access token, not the session token itself.
Shopify’s docs are explicit that an embedded app first loads unauthenticated and serves the frontend shell. After the frontend loads, requests to the backend include the session token in the authorization header. That matters because your initial HTML should be thin. It is a shell, not a treasure chest of protected data.
“When your embedded app first loads, it's unauthenticated”
In Rails terms, that usually means:
- one lightweight controller action for the embedded app shell
- authenticated JSON endpoints for actual protected app data
- service objects for Admin API work
- background jobs using persisted offline tokens where user context is not required
A simple shell controller can stay intentionally dumb:
class EmbeddedAppController < ApplicationController
def show
@shop_domain = params[:shop]
render :show
end
endThen the authenticated API route is where your real data work starts:
class Api::Embedded::DashboardController < ApplicationController
def show
render json: {
shop: Current.shop.shopify_domain,
shopify_user_id: Current.shopify_user_id,
enabled_features: Current.shop.enabled_features
}
end
endThe exact mechanism that populates Current.shop and
Current.shopify_user_id depends on your app architecture. The point is
the boundary. The gem or shared auth layer establishes trusted Shopify context. Your
controller handles app behavior on top of that.
Also note what this flow does not require. It does not require your React
frontend to know how to decode JWTs. It does not require local storage auth hacks. It
does not require a tiny homemade auth framework named something like
SessionWizardV2, which is always a bad omen.
How session tokens and Admin API tokens relate
One of the most common sources of confusion is treating all tokens like the same thing. They are not.
| Credential | What it does | Who uses it | What it should not be used for |
|---|---|---|---|
| Session token | Authenticates embedded frontend requests into your backend | Browser to Rails | Direct Admin API calls |
| Online access token | Authorizes backend calls with user-scoped permissions | Rails to Shopify Admin API | Long-lived background work |
| Offline access token | Authorizes shop-scoped backend work | Rails jobs, webhooks, durable services | User-specific permission-sensitive behavior |
Shopify’s online access token docs say online tokens are linked to an individual user, respect that user’s permissions, and expire when the user logs out or after 24 hours. That makes them appropriate when your backend behavior must reflect the currently authenticated admin user.
“Tokens with online access mode are linked to an individual user”
Shopify’s offline-token docs also now describe expiring offline tokens, refresh tokens, and refresh-token rotation behavior. So if your mental model is still “offline means immortal and therefore emotionally available forever”, it is time to update that model.
The practical split in Rails is usually this:
- session token for authenticating the incoming embedded request
- online access token when you need user-aware Admin API behavior
- offline access token for jobs, webhooks, and durable shop-level backend work
Shopify’s Ruby API docs also recommend using shopify_app if you are in
Rails when instantiating the session needed for authenticated API calls.
“To instantiate a session, we recommend you either use the
shopify_appif working in Rails”
The frontend pattern to keep
The frontend rule is wonderfully small:
- run inside App Bridge correctly
- make normal backend requests
- do not invent your own persistence layer for session tokens
Shopify’s current embedded authorization docs say that if App Bridge is properly set
up, it appends the authorization header automatically to your backend
requests. The session-token setup docs also note that the older manual
getSessionToken guide applies to App Bridge 2.0, while the current
version automatically adds session tokens to requests coming from your app.
“it'll append a new header field
authorizationto your server requests automatically”
So in a healthy embedded React frontend, a normal request often looks refreshingly unremarkable:
export async function loadDashboard() {
const response = await fetch("/api/embedded/dashboard", {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Dashboard request failed with ${response.status}`);
}
return response.json();
}That is the point. You want ordinary requests, not a dramatic monologue about auth on every button click.
The two frontend mistakes worth avoiding are:
- treating the session token like durable app state
- shipping protected merchant data inside the initial unauthenticated HTML shell
Shopify says session tokens live for one minute and must be fetched on each request so stale tokens are not used. Shopify also says the initial embedded app route is unauthenticated, and only the shop domain from the app URL should be available there.
“The lifetime of a session token is one minute.”
So no, local storage is not your friend here. It is just a drawer where expired JWTs go to become future bugs.
When token exchange happens
Token exchange is where many Rails developers accidentally start overthinking things. The healthy rule is simple:
Your frontend sends a valid session token to Rails. Rails verifies the embedded request through the gem-backed auth path. Then, if Rails does not already have a valid Admin API access token for the shop and context it needs, Rails exchanges the session token for one.
Shopify’s token-exchange docs say the backend can exchange a session token for either
an online or offline access token by calling /admin/oauth/access_token
with the token-exchange grant. Shopify’s embedded-authorization docs also explicitly
say not to perform token exchange on every request and to persist the access token
after exchange.
“Don’t exchange tokens on every request”
This is the key operational distinction:
- session-token verification is request authentication
- token exchange is Admin API credential acquisition
- persistent token storage is your app’s durable API session layer
The token-exchange endpoint shape looks like this:
require "net/http"
require "json"
class ShopifyTokenExchange
TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token"
ONLINE_ACCESS_TOKEN = "urn:shopify:params:oauth:token-type:online-access-token"
OFFLINE_ACCESS_TOKEN = "urn:shopify:params:oauth:token-type:offline-access-token"
def self.exchange!(shop_domain:, session_token:, requested_token_type:)
uri = URI("https://#{shop_domain}/admin/oauth/access_token")
response = Net::HTTP.post_form(uri, {
client_id: ENV.fetch("SHOPIFY_API_KEY"),
client_secret: ENV.fetch("SHOPIFY_API_SECRET"),
grant_type: TOKEN_EXCHANGE_GRANT,
subject_token: session_token,
subject_token_type: ID_TOKEN,
requested_token_type: requested_token_type,
})
body = JSON.parse(response.body)
raise "Token exchange failed: #{response.code} #{body}" unless response.is_a?(Net::HTTPSuccess)
body
end
endFor most normal Rails apps, though, the takeaway is not “great, now I can own the whole exchange flow manually.” The takeaway is “good, now I know what the gem and the platform are doing on my behalf.”
If you do need to manage the resulting tokens yourself, store them like real credentials. Session tokens are throwaway per-request auth. Access tokens are durable backend credentials. Mixing those up is how people end up building a security model that feels like a student group project.
When you actually need to understand the JWT internals
Even in a gem-first Rails app, the JWT internals still matter in a few situations:
- you are debugging bad shop resolution
- you are investigating expired or not-yet-valid token errors
- you are building a custom auth layer outside the usual gem flow
- you are handling a non-standard surface and need to inspect claims directly
Shopify’s session-token docs describe claims like aud,
dest, sub, sid, exp, and
nbf. The important practical rule is that dest and
aud are trusted claims, while loose query params are not. If your
request says one shop in the query string and the token says another, the token wins
and the query string can go sit quietly in the corner.
If you ever do have to verify manually, keep it small and specific:
require "jwt"
require "uri"
class ManualShopifySessionTokenVerifier
def self.call!(encoded_token)
payload, = JWT.decode(
encoded_token,
ENV.fetch("SHOPIFY_API_SECRET"),
true,
algorithm: "HS256"
)
raise "Invalid aud" unless payload.fetch("aud") == ENV.fetch("SHOPIFY_API_KEY")
dest = URI(payload.fetch("dest")).host
raise "Invalid dest" unless dest&.end_with?(".myshopify.com")
now = Time.now.to_i
raise "Expired token" if Integer(payload.fetch("exp")) <= now
raise "Token not active yet" if Integer(payload.fetch("nbf")) > now
{
shop_domain: dest,
shopify_user_id: payload["sub"],
shopify_session_id: payload["sid"],
payload: payload,
}
end
endBut again, this is the advanced section. It is for custom stacks, debugging, and escape hatches. It is not the main Rails recommendation.
The sanity-preserving rule
Understand the JWT internals deeply enough to debug them. Do not make manual verification your default Rails strategy unless you genuinely need a custom path.
Common Rails mistakes even with the gem
The gem helps a lot. It does not eliminate the possibility of self-inflicted damage.
Treating a valid session token like full authorization. It proves the request came from a valid embedded context. It does not mean the user may do everything in your app.
Using the session token as an Admin API token. Different job, different credential.
Exchanging on every request. Shopify explicitly says not to.
Trusting a
shopquery param over JWT claims. That is how you end up debugging the wrong shop in the right tab.Shipping protected data in the initial HTML shell. Shopify says that first route is unauthenticated.
Persisting session tokens server-side like reusable sessions. Session tokens are short-lived request credentials, not your durable store.
Ignoring token type semantics. Online is user-aware. Offline is durable. Pretending they are interchangeable creates weird permission bugs.
Thinking the frontend should solve backend auth. It should not. The frontend forwards the request. Rails owns verification, authorization, and API calls.
The smell test is simple. If your auth discussion includes the phrase “we just store it for now” more than once, something is already on fire.
If your app still mixes older OAuth-first assumptions with managed install and token exchange, the next thing to standardize is your installation and Admin API credential lifecycle, not more frontend auth choreography.
Best internal links
React frontend plus Rails backend for Shopify embedded apps
for the broader embedded app stack split.
Shopify OAuth and managed install in Rails apps
for installation, token acquisition, and managed-install architecture.
Session tokens for Shopify UI extensions explained
for extension-specific request flows that are not identical to the embedded admin case.
Sources and further reading
Shopify Dev: About session tokens
Shopify Dev: Set up session tokens
Shopify Dev: Set up embedded app authorization
Shopify Dev: Exchange a session token for an access token
Shopify Dev: About online access tokens
Shopify Dev: About offline access tokens
Shopify shopify_app README
shopify-api-ruby: Make a GraphQL API call
FAQ
Should I manually verify Shopify session tokens in a normal Rails embedded app?
Usually no. Shopify recommends the Shopify App gem for Rails, and the default generated embedded app already uses session tokens and token exchange. Manual verification is mainly for custom stacks, debugging, or unusual request flows.
Does a valid session token let my Rails backend call the Admin API directly?
No. The session token authenticates the embedded request into your backend. Your backend still needs an online or offline Admin API access token to call Shopify APIs.
Should my frontend cache session tokens?
No. Session tokens live for one minute. Treat them as request credentials, not durable client state.
Should Rails exchange the session token for an Admin API token on every request?
No. Shopify explicitly says not to exchange on every request. Persist the access token and only exchange when it is missing or expired.
Related resources
Keep exploring the playbook
Calling a Rails API from a Shopify customer account extension
A practical guide to calling Rails endpoints from a Shopify Customer Account UI Extension, including session-token verification, endpoint design, and the requests that should not go through your backend at all.
Checkout UI Extensions with a custom backend architecture
A practical backend architecture guide for Shopify Checkout UI Extensions, covering when to call your own backend, how to keep the extension thin, and where teams overbuild the server side.
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.