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.
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 you | What it does not give you | Why that distinction matters |
|---|---|---|
| Signed claims like app audience, shop context, issue time, expiry, and surface actor | Permission to run any backend action the caller dreams up at 2 a.m. | Authentication is not authorization |
| A trustworthy subject from the verified JWT | Trust in caller-supplied query params like ?customer_id=... | Client input is still untrusted input |
| A way to authenticate a call into your backend | A Shopify Admin API access token | Your 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.
| Surface | Typical actor in sub | Primary job of the token | Common mistake |
|---|---|---|---|
| Embedded admin app | Merchant-side Shopify user | Authenticate frontend-to-backend app requests | Confusing it with an Admin API token |
| Checkout UI extension | Checkout or buyer-associated context | Authenticate extension-to-backend requests in checkout | Trusting caller-supplied checkout or customer identifiers |
| Customer account UI extension | Optional customer GID | Authenticate extension-to-backend requests in customer accounts | Reusing 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:
- Read the bearer token from the
Authorizationheader. - Verify signature, expiry, not-before, audience, and shop context.
- Normalize verified claims into a small application context object.
- Attach the surface explicitly, because the route knows which surface it is serving.
- Authorize the requested action against that context.
- 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
endYou 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
endThis 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
endThe 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 assignlogged_in_customer_id. Use the session tokensubclaim 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
Shopify Dev: About session tokens
Shopify Dev: Set up embedded app authorization
Shopify Dev: Set up session tokens
Shopify Dev: Customer account UI extensions session token API
Shopify Dev: Customer account UI extensions configuration
Shopify Dev: Customer account UI extensions Storefront API
Shopify Dev: Checkout UI extensions configuration
Shopify Dev: Checkout UI extensions Storefront API
Shopify Dev: Checkout target API example showing sessionToken on the extension surface
Shopify Dev: Token exchange
Shopify Dev: authenticate.public.checkout
Shopify Dev: authenticate.public.customerAccount
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
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.
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.