Key Takeaways
- With managed installation plus token exchange, an embedded app never performs an OAuth redirect. Shopify handles install and consent; App Bridge mints a short-lived session token your backend verifies on every request.
- Verify the session token's signature first, then its
exp,nbf, andaudclaims. A valid signature alone is not enough.- Store the verified shop domain in a tamper-proof attributes bag, never from a
?shop=query param, or you open a cross-tenant IDOR.- On a 401, the client retries once with a fresh token. It never redirects to an install URL, because a real reinstall is Shopify's job, not the app's.
The old way to authenticate an embedded Shopify app was a redirect dance: bounce the merchant to an OAuth authorize URL, catch the callback, exchange a code for an access token, then redirect back into the iframe. It was slow, it flickered, and it fought the embedded experience. Shopify's managed installation plus token exchange removes all of it (Shopify, Authenticate an embedded app without a template).
This guide documents how our Laravel backend implements that flow end to end: how VerifyShopifySession validates the session token, how EnsureShopActive provisions and refreshes the offline access token via token exchange, and how the frontend retries exactly once on a 401 instead of redirecting. The security details (claim validation, the attributes-bag rule) are where most hand-rolled implementations go wrong, so they get their own steps.
How Token Exchange Replaces the Redirect Dance
Managed installation moves install and consent to Shopify. Your shopify.app.toml declares scopes, use_legacy_install_flow = false, and Shopify installs the app and manages scope changes. Your app never sees an authorization code.
At runtime, the embedded frontend renders immediately and asks App Bridge for a session token (a short-lived JWT proving the current merchant session). It sends that token as Authorization: Bearer on every backend request. The backend verifies the token, and the first time it needs an access token, it exchanges the session token with Shopify for an offline access token (Shopify, token exchange). No redirect, no code, no install URL in the app.
The request path, every time:
Step 1: Verify the Session Token Correctly
In this step you'll validate the incoming session token, checking the signature and then the standard JWT claims in the right order.
The token is a JWT with three dot-separated parts. Verify the signature before trusting any payload data, using HMAC-SHA256 with your app secret:
// app/Http/Middleware/VerifyShopifySession.php
$parts = explode('.', $sessionToken);
if (count($parts) !== 3) {
throw new Exception('Invalid token format');
}
// Verify signature BEFORE trusting payload data.
$signature = hash_hmac('sha256', $parts[0].'.'.$parts[1], config('shopify.api_secret'), true);
$providedSignature = $this->base64UrlDecode($parts[2]);
if (! hash_equals($signature, $providedSignature)) {
throw new Exception('Invalid token signature');
}
$payload = json_decode(base64_decode($this->base64UrlToBase64($parts[1])), true);
if (! $payload || ! isset($payload['dest'])) {
throw new Exception('Invalid token payload');
}A valid signature is necessary but not sufficient. Validate the standard claims so a leaked or old token cannot be replayed and a token minted for a different app cannot be reused:
$now = time();
$leeway = (int) config('shopify.jwt_leeway', 5);
if (isset($payload['exp']) && $now >= ((int) $payload['exp'] + $leeway)) {
throw new Exception('Token expired');
}
if (isset($payload['nbf']) && $now < ((int) $payload['nbf'] - $leeway)) {
throw new Exception('Token not yet valid');
}
$expectedAudience = config('shopify.api_key');
if ($expectedAudience && (($payload['aud'] ?? null) !== $expectedAudience)) {
throw new Exception('Invalid token audience');
}What just happened: you used hash_equals for a timing-safe signature comparison, then checked expiry (exp), not-before (nbf) with a small leeway for clock skew, and audience (aud) so a token issued for another app is rejected. Skipping the aud check is a common and dangerous omission.
Watch out: the shop domain comes from the signed
destclaim, not from anything the client can set freely. That is the whole point of validating the signature first.
Step 2: Trust Only the Verified Shop (Avoid Cross-Tenant IDOR)
In this step you'll store the verified shop where downstream code cannot be tricked into reading an attacker-supplied value instead.
Extract the shop from the dest claim and put it in the request's attributes bag, which is never populated from client input:
$shopDomain = str_replace('https://', '', $payload['dest']);
// Store in the attributes bag, NOT the input bag. The attributes bag is never
// populated from client request data, so downstream middleware cannot be
// tricked into using a `?shop=` query/body param instead of this verified value.
$request->attributes->set('shopify_shop', $shopDomain);
return $next($request);Downstream, read only that verified value:
// app/Http/Middleware/EnsureShopActive.php
$shop = $request->attributes->get('shopify_shop');
if (! $shop) {
return response()->json(['error' => 'Shop domain required'], 400);
}What just happened: by sourcing the shop exclusively from the signed token and the tamper-proof attributes bag, you close a cross-tenant IDOR. If you had fallen back to a ?shop= query parameter or a client header, one merchant could pass another store's domain and act on that store's data. This single rule is the most security-critical line in the whole flow.
Step 3: Provision and Refresh via Token Exchange
In this step you'll get an access token the first time a shop appears, and refresh it on demand afterward, all without a redirect.
If the shop has no stored token yet, provision one by exchanging the session token. If it has one, make sure it is still usable before handing off:
// app/Http/Middleware/EnsureShopActive.php (core branch)
$shopRecord = $this->installationService->getAuthenticatedShop($shop);
if ($shopRecord) {
try {
$this->tokenService->accessTokenFor($shopRecord); // refresh expiring tokens on demand
} catch (NeedsReauthException $e) {
// Refresh impossible (refresh token expired/revoked). With a session
// token we can re-provision without any redirect.
$shopRecord = $sessionToken
? $this->provisionViaTokenExchange($shop, $sessionToken)
: null;
if (! $shopRecord) {
return $this->unauthenticated($shop);
}
}
} else {
// No stored token yet -> provision via token exchange (managed install).
if (! $sessionToken) {
return $this->unauthenticated($shop);
}
$shopRecord = $this->provisionViaTokenExchange($shop, $sessionToken);
if (! $shopRecord) {
return $this->unauthenticated($shop);
}
}
$request->merge(['authenticated_shop' => $shopRecord]);
return $next($request);The provisioning service exchanges the session token for an offline token and persists the shop:
// app/Services/InstallationService.php
public function handleTokenExchange(string $shop, string $sessionToken): Shop
{
$tokenData = $this->shopifyApi->exchangeSessionToken($shop, $sessionToken);
$shopInfo = $this->shopifyApi->getShopInfo($shop, $tokenData['access_token']);
$shopModel = $this->shopRepo->upsertFromOAuth($shop, [/* ...shop info... */]);
$this->sessionRepo->upsertOfflineSession($shop, $tokenData['access_token'], $tokenData['scope'] ?? null);
event(new ShopInstalledEvent($shopModel)); // first provision only
return $shopModel;
}What just happened: provisioning and refresh both happen inline, during a normal API request, with no user-visible redirect. The access token is stored encrypted at rest and never exposed to the frontend. Note the NeedsReauthException path: even when a refresh token is dead, you re-provision from the current session token rather than bouncing to OAuth.
Step 4: Return a Retry Signal, Not a Redirect
In this step you'll handle an invalid or expired session token the managed-install way: a 401 with Shopify's retry header, so the client fetches a fresh token and tries again.
// Both VerifyShopifySession and EnsureShopActive return this on failure.
private function unauthenticated(string $shop)
{
return response()->json(
['error' => 'Shop not authenticated'],
401,
['X-Shopify-Retry-Invalid-Session-Request' => '1'] // ask the client to retry with a fresh token
);
}The frontend's Axios response interceptor retries the request exactly once. Because the request interceptor fetches a fresh shopify.idToken() on every call, the retry automatically carries a new token:
// src/api/client.js
apiClient.interceptors.request.use(async (config) => {
const token = await shopify.idToken(); // fresh JWT per request
config.headers.Authorization = `Bearer ${token}`;
return config;
});
apiClient.interceptors.response.use(
(response) => response.data,
async (error) => {
const { status, config } = { status: error.response?.status, config: error.config };
// Managed install: a 401 means the session token was invalid/expired.
// Retry ONCE; the request interceptor attaches a fresh idToken(). No OAuth redirect.
if (status === 401 && config && !config._retried) {
config._retried = true;
return apiClient(config);
}
return Promise.reject({ status, message: error.response?.data?.message || 'Something went wrong' });
},
);What just happened: an expired token self-heals in one round trip. The _retried flag prevents an infinite loop if the second attempt also fails. Critically, there is no branch that redirects to an OAuth or install URL and no prompt asking the merchant to type their .myshopify.com domain. A genuine reinstall is handled by Shopify managed installation, outside the app.
Verification
- No redirects: load the embedded app and watch the network tab; you should see bearer-token API calls, never a 302 to an OAuth authorize URL.
- First-request provisioning: install on a fresh dev store and confirm the first authenticated API call triggers token exchange and persists the shop.
- Expiry self-heal: force an invalid session token and confirm the client receives a 401 with
X-Shopify-Retry-Invalid-Session-Request: 1, then retries once and succeeds. - Claim validation: craft a token with a wrong
audand confirm it is rejected; craft one with a futurenbfand confirm the leeway behaves as configured. - Cross-tenant guard: append
?shop=other-store.myshopify.comto a request and confirm the backend ignores it and uses the signeddestvalue.
Key Takeaways
1. Managed install plus token exchange deletes the redirect dance. The app renders immediately and authenticates per request with a session token. There is no authorization-code flow to maintain.
2. Signature first, then claims. Verify the HMAC signature before reading the payload, then enforce exp, nbf, and aud. The audience check is the one people forget.
3. Source the tenant from the signed token only. Put the verified shop in the attributes bag and never fall back to client-supplied parameters. This is the line that prevents cross-tenant data access.
4. 401 means retry, not redirect. Return Shopify's retry header and let the client reissue with a fresh token. Reinstalls belong to Shopify, not your code.
Frequently Asked Questions
Do I still need an OAuth callback route with token exchange?
No. With managed installation, Shopify handles install and consent, and token exchange gets you an access token from the session token. We removed the /auth/install and /auth/callback routes entirely; there is no authorization-code flow left in the app.
How does the app get an access token on the very first request?
The first authenticated request carries a session token. EnsureShopActive sees no stored token for the shop and calls handleTokenExchange, which exchanges the session token for an offline access token and persists the shop (Shopify, token exchange). The access token is stored encrypted and never sent to the frontend.
What stops a request from acting on another merchant's store?
The shop domain is read only from the signed dest claim and stored in the request attributes bag, which client input never populates. Any ?shop= query or body parameter is ignored. Without this discipline, a crafted parameter would be a cross-tenant IDOR.
Why retry once on 401 instead of redirecting to login?
Because managed-install apps have no login URL to redirect to. A 401 almost always means the short-lived session token expired, so fetching a fresh shopify.idToken() and retrying once resolves it transparently. The single-retry guard prevents loops if the second attempt also fails.
Written by Duke, based on the production implementation of the Flicky embedded Shopify app. Code samples are drawn from the shipping codebase and simplified for readability. See also our engineering notes hub.