Guides

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.

Updated March 12, 2026
15 min read
Editorial note: Most Rails session-token problems are not cryptography problems. They are boundary problems. The gem handles the normal path, the frontend forgets what still matters, and the backend quietly confuses authentication with authorization.

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 dev

That 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:

  1. Shopify Admin loads your embedded app shell.
  2. App Bridge authenticates requests from the frontend to your backend using a session token.

  3. 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.

  4. 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
end

Then 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
end

The 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.

CredentialWhat it doesWho uses itWhat it should not be used for
Session tokenAuthenticates embedded frontend requests into your backendBrowser to RailsDirect Admin API calls
Online access tokenAuthorizes backend calls with user-scoped permissionsRails to Shopify Admin APILong-lived background work
Offline access tokenAuthorizes shop-scoped backend workRails jobs, webhooks, durable servicesUser-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_app if 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 authorization to 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
end

For 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
end

But 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 shop query 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

Sources and further reading

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