Guides

Developer guide

Session tokens for Shopify UI extensions explained

A guide to session tokens in Shopify UI extensions, with the differences between embedded app auth, checkout extensions, and customer account extensions explained in architecture terms that matter in production.

Updated March 12, 2026
16 min read
Editorial note: This is the version for teams whose app backend serves more than one Shopify surface and keeps mixing up request authentication, app authorization, and Shopify API access tokens.

What this article is really about

The confusing part of Shopify auth is not that it is impossibly complex. The confusing part is that developers keep putting three different things into one mental bucket and calling all of it auth.

Bucket one is request authentication between a Shopify-hosted frontend surface and your backend. That is where session tokens live. Bucket two is app installation and access to Shopify APIs. That is where OAuth, managed install, and token exchange live. Bucket three is your own app’s authorization rules. That is where you decide whether a checkout extension may apply a loyalty code, whether a customer account extension may read account-specific data, or whether an embedded admin screen may trigger a destructive merchant action.

When those buckets get mixed together, people build endpoints that accept a valid token and then do basically whatever the caller asks. That is how you end up with a route that means valid JWT = god mode, which is a bold architecture choice in the same way that storing passwords in a sticky note is a bold security strategy.

“A session token is a mechanism that lets your embedded app authenticate the requests that it makes between the client side and your app's backend.”

The working rule

Treat session tokens as proof about request context, not as permission to do arbitrary work.

What stays the same across UI extension surfaces

Across Shopify surfaces, the stable model is this: the surface gets a Shopify-issued signed token, your frontend sends it to your backend, and your backend verifies the token before trusting any of the context inside it.

Shopify's embedded app docs describe session tokens as JWTs signed with the shared secret between Shopify and your app, with payload claims such as iss, dest, aud, sub, exp, nbf, iat, jti, and sid. That gives your backend a way to verify who the token was issued for, which shop it belongs to, and whether it is still valid.

What the token gives youWhat it does not give youWhy that distinction matters
Signed claims like app audience, shop context, issue time, expiry, and surface actorPermission to run any backend action the caller dreams up at 2 a.m.Authentication is not authorization
A trustworthy subject from the verified JWTTrust in caller-supplied query params like ?customer_id=...Client input is still untrusted input
A way to authenticate a call into your backendA Shopify Admin API access tokenYour backend still needs its own Shopify API access strategy

This is also the part many teams underappreciate: the token is only useful if the backend chooses a narrow interpretation of it. In production, the right move is to derive a normalized application context from verified claims, then pass that context into service objects or command handlers that know what the current surface is allowed to do.

In other words, the JWT should unlock context, not chaos.

What changes between embedded admin checkout and customer accounts

The token idea is shared. The runtime, actor, and product expectations are not.

Embedded admin app

In the embedded admin app model, Shopify App Bridge is the key piece. Shopify's current docs say App Bridge automatically adds session tokens to requests from your app, and the embedded authorization docs say those requests include an authorization header when App Bridge is correctly set up. Shopify also documents the embedded app session token lifetime as one minute, which is why the correct pattern is to fetch a fresh token per request instead of caching it in your frontend and hoping nobody notices.

“The lifetime of a session token is one minute.”

The sub claim in the embedded app payload refers to the Shopify user making the request, which is merchant-side context. That matters because your backend should interpret a valid embedded app token as “a merchant user inside the admin is calling me”, not as “a customer is calling me” and not as “I now magically possess Admin API permissions.”

Checkout UI extensions

Checkout extensions run inside Shopify's checkout extension runtime, not inside your embedded admin app. Shopify's checkout docs still document session-token-backed calls to your API server, and checkout target APIs expose a sessionToken on the extension API surface. The docs for checkout targets describe that token as a signed JWT with a five-minute TTL, refreshing with a new signature and expiry when needed.

That five-minute detail is interesting because it is not the same lifetime described in the embedded admin docs. The practical conclusion is simple: do not hardcode business logic around a specific TTL. Just fetch a valid token whenever you make a backend request. Shopify's extension APIs are already designed around that usage pattern.

Checkout also has stricter network semantics. If you want external calls, you need network_access = true, approval in the Partner Dashboard, and a backend that accepts cross-origin requests. Shopify's checkout configuration docs also make an important security point: a verified token gives you claim integrity, but it does not prove that every aspect of the HTTP request came from Shopify. So yes, trust the token's subject claim. No, do not trust a random customer_id query param sent next to it.

Customer account UI extensions

Customer account extensions are similar to checkout in shape, but the principal is different. Shopify's customer account session token docs say shopify.sessionToken.get() should be called every time you need to make a backend request, returning a valid or cached token as appropriate. The docs also say the optional sub claim contains the customer GID when the customer is logged in and your app has permission to read customer accounts.

That makes customer account extensions the easiest place to accidentally blur identities. A valid token here usually means “this customer account surface is calling my backend”, not “my embedded app is calling”, not “my storefront JavaScript is calling”, and certainly not “I can skip authorization because Shopify handled everything for me.” Shopify handled the signing. You still handle what the signed caller may do.

SurfaceTypical actor in subPrimary job of the tokenCommon mistake
Embedded admin appMerchant-side Shopify userAuthenticate frontend-to-backend app requestsConfusing it with an Admin API token
Checkout UI extensionCheckout or buyer-associated contextAuthenticate extension-to-backend requests in checkoutTrusting caller-supplied checkout or customer identifiers
Customer account UI extensionOptional customer GIDAuthenticate extension-to-backend requests in customer accountsReusing merchant-side authorization assumptions

One more important runtime detail: customer account UI extensions run in a Web Worker with a null origin, and Shopify requires your backend to support CORS with Access-Control-Allow-Origin: *. Checkout extensions are also worker-based and cross-origin from your backend. So cookie-based assumptions, same-origin assumptions, and “the browser session will sort it out” assumptions should all be escorted out of the building.

The backend contract to standardize on

If one Rails app serves embedded admin pages, checkout UI extensions, and customer account UI extensions, standardize on a shared verification layer and separate authorization paths.

The pattern I recommend is this:

  1. Read the bearer token from the Authorization header.
  2. Verify signature, expiry, not-before, audience, and shop context.
  3. Normalize verified claims into a small application context object.
  4. Attach the surface explicitly, because the route knows which surface it is serving.
  5. Authorize the requested action against that context.
  6. Only then perform durable work, Shopify API calls, or writes to your own database.

That context object should usually look like this:

export type UiSurface = 'embedded_admin' | 'checkout' | 'customer_account';
 
export type VerifiedAppContext = {
  surface: UiSurface;
  shopDomain: string;
  actorSubject: string | null;
  sessionId: string | null;
  jwtId: string | null;
  claims: Record<string, unknown>;
};

The important design choice is not the exact type shape. The important choice is that all downstream code receives a normalized object, not a raw JWT payload and definitely not a free-for-all request body.

Do not over-generalize the endpoint

Shared verification code is great. A single catch-all endpoint for every extension surface is usually a trap.

Also notice what is absent from that context object: there is no trusted customerId field copied from query params, no trusted shop field copied from JSON, and no inferred permissions like can_apply_discount: true just because the token verified. Those decisions belong to your own authorization layer.

When not to call your backend at all

Teams often send requests to their own server out of habit, not need. That adds latency, CORS work, and another moving part to fail during checkout, which is the most expensive place to do improv theater.

Shopify's checkout docs say that with api_access enabled, extensions can query the Storefront API directly with the standard query helper or the custom fetch global, without manually managing token acquisition or refresh. Customer account extensions expose a similar Storefront API query surface, and their configuration docs explicitly recommend metafields as a faster alternative to an external network call when the data can be prepared ahead of time.

So the backend should be reserved for app-owned logic, secrets, durable state, cross-system orchestration, or operations that Shopify's extension runtime cannot safely do for you. If all you need is a metafield, product tag, or simple storefront read, do not build a round trip to Rails just because Rails is standing there looking enthusiastic.

import '@shopify/ui-extensions/preact';
import {render} from 'preact';
import {useEffect, useState} from 'preact/hooks';
 
export default function extension() {
  render(<Extension />, document.body);
}
 
function Extension() {
  const {query} = shopify;
  const [productTitles, setProductTitles] = useState<string[]>([]);
 
  useEffect(() => {
    query(`
      query DemoProducts($first: Int!) {
        products(first: $first) {
          nodes {
            id
            title
          }
        }
      }
    `, {
      variables: {first: 3},
    })
      .then(({data, errors}) => {
        if (errors?.length) throw new Error(errors[0].message);
        setProductTitles(data.products.nodes.map((node) => node.title));
      })
      .catch(console.error);
  }, [query]);
 
  return (
    <s-unordered-list>
      {productTitles.map((title) => (
        <s-list-item key={title}>{title}</s-list-item>
      ))}
    </s-unordered-list>
  );
}

The thin-extension rule is not “never call your backend.” It is “only call your backend when the backend owns something meaningful.”

Rails implementation pattern

Rails is a good fit here because the verification and normalization logic can live in one place while surface- specific authorization lives in dedicated controllers or command objects.

1. Use a tiny frontend fetch wrapper

For checkout and customer account extensions, the frontend should get a fresh session token right before calling your backend. That keeps the extension code boring, which is exactly what you want from authentication plumbing.

export async function callAppBackend(path: string, init: RequestInit = {}) {
  const token = await shopify.sessionToken.get();
 
  return fetch(`https://app.example.com${path}`, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      ...(init.headers || {}),
      Authorization: `Bearer ${token}`,
    },
  });
}

For embedded admin app requests, App Bridge can already append the authorization header automatically when set up correctly. The point is the same either way: do not invent your own token storage system in localStorage like you are building a tiny insecure bank.

2. Verify the JWT in a dedicated service

Shopify's docs recommend using the Shopify App gem or Shopify Node library to decode and verify session tokens. If you are in a custom Rails setup, manual verification is still straightforward. Verify the signature with HS256, check the audience against your app's client ID, and validate timing claims before trusting the payload.

# app/services/shopify/session_token_verifier.rb
require "jwt"
require "uri"
 
module Shopify
  class SessionTokenVerifier
    ALGORITHM = "HS256"
 
    class InvalidToken < StandardError; end
 
    def initialize(token:, api_key:, api_secret:)
      @token = token.to_s.sub(/\ABearer\s+/i, "")
      @api_key = api_key
      @api_secret = api_secret
    end
 
    def call
      raise InvalidToken, "Missing token" if @token.empty?
 
      payload, = JWT.decode(
        @token,
        @api_secret,
        true,
        algorithm: ALGORITHM,
        verify_aud: true,
        aud: @api_key,
        verify_iat: true,
        verify_expiration: true,
        verify_not_before: true
      )
 
      shop_domain = normalize_shop_domain!(payload.fetch("dest"))
      verify_issuer_matches_shop!(payload["iss"], shop_domain)
 
      {
        shop_domain: shop_domain,
        actor_subject: payload["sub"],
        session_id: payload["sid"],
        jwt_id: payload["jti"],
        issued_at: Time.at(payload.fetch("iat")),
        expires_at: Time.at(payload.fetch("exp")),
        claims: payload,
      }
    rescue JWT::DecodeError, JWT::VerificationError, KeyError => e
      raise InvalidToken, e.message
    end
 
    private
 
    def normalize_shop_domain!(value)
      uri = if value.include?("://")
        URI.parse(value)
      else
        URI.parse("https://#{value}")
      end
 
      host = uri.host
      raise InvalidToken, "Invalid destination host" if host.nil? || !host.end_with?(".myshopify.com")
 
      host
    rescue URI::InvalidURIError
      raise InvalidToken, "Invalid destination URI"
    end
 
    def verify_issuer_matches_shop!(issuer, shop_domain)
      return if issuer.blank?
 
      uri = URI.parse(issuer)
      raise InvalidToken, "Issuer mismatch" unless uri.host == shop_domain
    rescue URI::InvalidURIError
      raise InvalidToken, "Invalid issuer URI"
    end
  end
end

You can make this stricter if you want replay protection by storing recent jti values for very sensitive commands, but most apps should first focus on getting the basic claim validation and route authorization correct. That alone fixes the majority of production auth nonsense.

3. Convert verified claims into surface-specific context

# app/services/current_app_context_builder.rb
class CurrentAppContextBuilder
  def self.build(surface:, verified_token:)
    OpenStruct.new(
      surface: surface,
      shop_domain: verified_token.fetch(:shop_domain),
      actor_subject: verified_token[:actor_subject],
      session_id: verified_token[:session_id],
      jwt_id: verified_token[:jwt_id],
      claims: verified_token.fetch(:claims)
    )
  end
end

This lets your policy layer ask clear questions such as:

  • Is this route callable from checkout at all?
  • Does this customer account request have a customer sub?
  • Does this embedded admin action require a merchant user subject?
  • Does this command require protected customer data access or a shop-level app installation record?

4. Keep public extension routes separate

I strongly prefer separate public namespaces over one giant /api/extensions blob.

# config/routes.rb
namespace :public do
  namespace :checkout do
    post :loyalty_offer, to: "loyalty_offers#create"
  end
 
  namespace :customer_account do
    get :loyalty_balance, to: "loyalty_balances#show"
  end
end
# app/controllers/public/customer_account/base_controller.rb
class Public::CustomerAccount::BaseController < ActionController::API
  before_action :set_cors_headers
  before_action :authenticate_customer_account_extension!
 
  private
 
  def authenticate_customer_account_extension!
    verified = Shopify::SessionTokenVerifier.new(
      token: request.headers["Authorization"],
      api_key: ENV.fetch("SHOPIFY_API_KEY"),
      api_secret: ENV.fetch("SHOPIFY_API_SECRET")
    ).call
 
    @current_app_context = CurrentAppContextBuilder.build(
      surface: "customer_account",
      verified_token: verified
    )
  rescue Shopify::SessionTokenVerifier::InvalidToken
    render json: {error: "Unauthorized"}, status: :unauthorized
  end
 
  def set_cors_headers
    response.set_header("Access-Control-Allow-Origin", "*")
    response.set_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
    response.set_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
  end
end

The same pattern works for checkout routes. Surface-specific base controllers keep your authorization rules local, testable, and much harder to accidentally weaken.

5. If you use Shopify's React Router helpers, let them do the heavy lifting

Shopify now documents public authentication helpers for checkout and customer account requests in the app framework packages. If you are on that stack, use them instead of hand-rolling the same logic twice.

// checkout route
import {json} from '@remix-run/node';
import {authenticate} from '../shopify.server';
 
export const loader = async ({request}) => {
  const {sessionToken, cors} = await authenticate.public.checkout(request);
 
  return cors(json({
    shop: sessionToken.dest,
    actor: sessionToken.sub,
  }));
};
// customer account route
import {json} from '@remix-run/node';
import {authenticate} from '../shopify.server';
 
export const loader = async ({request}) => {
  const {sessionToken, cors} = await authenticate.public.customerAccount(request);
 
  return cors(json({
    shop: sessionToken.dest,
    actor: sessionToken.sub,
  }));
};

This is one of those rare cases where the boring framework path is also the smart path.

Mistakes that create auth confusion

  • Assuming a valid session token is the same as a Shopify API access token. It is not. A session token authenticates the request into your backend. Your backend still needs the right app access token strategy for Admin API calls.

  • Hardcoding TTL assumptions. Shopify's embedded app docs describe a one-minute lifetime, while checkout and customer account extension docs describe token access patterns that refresh and cache valid tokens, and checkout target docs describe a five-minute TTL. Code for fresh retrieval per request, not for a magic number tattooed into a helper.

  • Using one generic endpoint for every surface. Shared verification code is good. Shared authorization assumptions are not.

  • Trusting proxy query params like logged_in_customer_id. Shopify's checkout and customer account extension docs explicitly say app proxy requests from UI extensions do not assign logged_in_customer_id. Use the session token sub claim instead.

  • Relying on browser session cookies. UI extensions run in worker contexts and cross-origin conditions. Design them as stateless authenticated callers.

  • Sending every read through your backend. If Shopify already provides Storefront API access or metafields for the job, your backend is just adding latency and another place for production to become dramatic.

  • Calling every problem “OAuth”. Many real bugs here are request authentication bugs, surface-authorization bugs, CORS bugs, or endpoint design bugs. The OAuth part is often not the part that broke.

If your team keeps tripping on this, the short correction is: session token verifies who is calling, your route determines which surface is calling, and your policy layer decides what that surface may actually do.

Best internal links

Sources and further reading

FAQ

Are UI extension session tokens the same thing as Shopify API access tokens?

No. Session tokens authenticate a frontend or extension request to your backend. Admin API, Storefront API, and Customer Account API access each have their own access model.

Can I trust a customer_id query parameter if the request also includes a valid session token?

No. Trust verified claims from the token, not caller-supplied identifiers in query params or JSON bodies.

Should checkout and customer account extensions hit one generic backend endpoint?

Usually no. Shared token verification is fine, but surface-specific endpoints or command handlers are much easier to authorize, test, and reason about.

Do I need cookies or session state for extension requests?

Do not depend on browser session cookies. UI extensions run in isolated worker contexts and should be treated as stateless authenticated callers.

Related resources

Keep exploring the playbook

Guides

When app proxies do not work for Shopify UI extensions

An explanation of why Shopify app proxies are the wrong integration path for many UI extension scenarios, especially in checkout and customer account surfaces where session tokens and extension APIs are the real auth model.

guidesShopify developerapp proxy