Building an embedded Shopify app in Symfony: session tokens, token exchange, and the parts nobody writes in PHP
Shopify's docs assume Node and Remix. Here is the whole authentication path in Symfony 7.4: session token verification, OAuth token exchange with expiring tokens, a stateless firewall, webhook HMAC, encrypted tokens at rest, and the CSP the App Store checks. From an app live in the Shopify App Store.
Shopify's app documentation has exactly one first-class path: Node, the official CLI, and a Remix template that wires authentication for you. Pick any other language and you leave the paved road at the first turn, because everything interesting happens before your framework's router sees the request: the app runs in an iframe inside the Shopify admin, the browser will not give you a cookie there, and the token you need to call the Admin API has to be traded for on the fly.
None of that is hard. It is just undocumented outside JavaScript. This post is the complete authentication and security path for an embedded Shopify app written in Symfony 7.4 on PHP 8.5, taken from StockPilot, an app that passed Shopify's App Store review and is live today. Every snippet below is production code, not a sketch.
The one architectural decision everything else follows from
An embedded app is a page inside admin.shopify.com, in a cross-origin
iframe. Third-party cookies are dead in that context, so a PHP session
is not available to you and never will be. Shopify's answer is a
short-lived JWT called a session token: App Bridge (their JS shim,
served from Shopify's CDN) mints one per request, valid for one minute,
and attaches it to every same-origin fetch() your page makes.
Which forces a split most PHP developers do not make by default:
- The HTML shell is public and carries no shop data. It is a layout, a nav, and empty containers. Serving it requires no authentication, sets no cookie, and reveals nothing.
- Every byte of merchant data is behind
/api, authenticated by the session token on each call.
That split is worth stating out loud because it inverts the usual Symfony instinct (secure the controller, render the data server-side). Here, rendering data into the shell would mean authenticating a page load that has no credentials to authenticate with. The payoff is that the shell is trivially cacheable and the security surface is one firewall over one path prefix.
# config/packages/security.yaml
firewalls:
# Webhooks authenticate via HMAC signature in the controller, not a firewall.
webhooks:
pattern: ^/webhooks
security: false
# Embedded admin API: App Bridge session token (JWT) on every request.
api:
pattern: ^/api
stateless: true
provider: users_in_memory
custom_authenticators:
- App\Security\SessionTokenAuthenticator
main:
lazy: true
provider: users_in_memory
access_control:
- { path: ^/api, roles: ROLE_SHOP }
stateless: true is not decoration. It tells Symfony not to try to
store the token in a session it does not have.
Verifying a session token: five checks, not one
The session token is a JWT signed HS256 with your app's client secret.
Verifying the signature is the part everyone does. The four claim checks
after it are the part that gets skipped, and each one closes a real hole:
without aud you accept tokens minted for a different app that happens
to share nothing but the algorithm; without the iss/dest match you
accept a token that claims one shop in one place and another shop
elsewhere.
// src/Shopify/SessionTokenVerifier.php
$expected = hash_hmac('sha256', $encodedHeader.'.'.$encodedPayload, $this->apiSecret, true);
if (null === $signature || !hash_equals($expected, $signature)) {
throw new InvalidSessionTokenException('Invalid JWT signature.');
}
$now = $this->clock->now()->getTimestamp();
// exp / nbf, with a small leeway for clock skew
if (!\is_int($exp) || $now - self::LEEWAY_SECONDS >= $exp) {
throw new InvalidSessionTokenException('Session token has expired.');
}
// aud must be *our* client id
$audiences = \is_array($aud) ? $aud : [$aud];
if (!\in_array($this->apiKey, $audiences, true)) {
throw new InvalidSessionTokenException('Session token audience mismatch.');
}
// dest is the shop; iss must live on the same host
$shopDomain = parse_url($dest, \PHP_URL_HOST);
if (!\is_string($shopDomain) || !ShopDomain::isValid($shopDomain)) {
throw new InvalidSessionTokenException('dest claim is not a valid shop domain.');
}
if (parse_url($iss, \PHP_URL_HOST) !== $shopDomain) {
throw new InvalidSessionTokenException('iss and dest claims do not match.');
}
Two details that cost time if you get them wrong. Use hash_equals, not
===, on the signature: this is the textbook timing-attack surface and
it costs one function name. And take the clock from
Symfony\Component\Clock\ClockInterface rather than calling time(),
so your test suite can produce an expired token without sleeping.
A five-second leeway on exp and nbf is deliberate. The token lives
sixty seconds; a server clock a couple of seconds behind Shopify's
would otherwise reject perfectly good tokens at a rate that looks like a
random, unreproducible bug.
Token exchange, and the expiring=1 that returns 403 without it
A session token proves who is asking. It does not let you call the
Admin API. For that you trade it for an access token, using OAuth 2.0
token exchange (RFC 8693) rather than the old authorization-code dance
with redirects. With managed installation, this is the entire install
flow: no /auth route, no redirect, no callback. The first authenticated
request from a new shop simply performs the exchange.
// src/Shopify/TokenExchanger.php
private const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange';
private const SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token';
private const OFFLINE_TOKEN_TYPE = 'urn:shopify:params:oauth:token-type:offline-access-token';
public function exchangeForOfflineToken(string $shopDomain, string $sessionToken): AccessToken
{
return $this->requestToken($shopDomain, [
'grant_type' => self::GRANT_TYPE,
'subject_token' => $sessionToken,
'subject_token_type' => self::SUBJECT_TOKEN_TYPE,
'requested_token_type' => self::OFFLINE_TOKEN_TYPE,
'expiring' => 1,
]);
}
That last line is the one to copy. Ask for a non-expiring offline token and the Admin API answers 403 (observed live on 2 July 2026), with an error that does not say so. An expiring token lives about an hour and is renewed with a refresh token, which Shopify rotates on every call: whatever persists your tokens has to write the new refresh token back, or your background jobs work for an hour and then stop.
Offline (not online) is the right request for anything that runs without a merchant present: webhook handlers, nightly digests, sync jobs.
The authenticator, and the header that saves your error rate
Symfony's custom authenticator maps onto this cleanly. The passport is self-validating because the JWT signature is the credential check.
// src/Security/SessionTokenAuthenticator.php
public function supports(Request $request): bool
{
return str_starts_with($request->headers->get('Authorization', ''), 'Bearer ');
}
public function authenticate(Request $request): Passport
{
$jwt = substr($request->headers->get('Authorization', ''), \strlen('Bearer '));
try {
$sessionToken = $this->sessionTokenVerifier->verify($jwt);
$shop = $this->shopInstaller->ensureInstalled($sessionToken);
} catch (InvalidSessionTokenException|TokenExchangeFailedException $e) {
throw new CustomUserMessageAuthenticationException($e->getMessage(), [], 0, $e);
}
return new SelfValidatingPassport(new UserBadge(
$sessionToken->shopDomain,
static fn (): ShopUser => new ShopUser($shop, $sessionToken->subject),
));
}
private function unauthorized(): JsonResponse
{
return new JsonResponse(
['error' => 'Unauthorized.'],
Response::HTTP_UNAUTHORIZED,
['X-Shopify-Retry-Invalid-Session-Request' => '1'],
);
}
X-Shopify-Retry-Invalid-Session-Request is worth a paragraph of its
own. Tokens last sixty seconds, so a page left open on a merchant's
second monitor will eventually fire a request with a token that expired
while they were in another tab. Return a bare 401 and the merchant sees
an error. Return 401 with that header and App Bridge silently fetches
a fresh token and retries once. One header turns a class of user-visible
failures into nothing at all.
Note also where installation happens: ensureInstalled() inside
authenticate(). A shop that appears with a valid token and no row in
your database is a new install, and the token exchange happens right
there. There is no separate install endpoint to secure.
Webhooks: raw body, constant time, and the query HMAC that is not http_build_query
Webhooks carry no session token. They are signed: HMAC-SHA256 over the
raw request body, base64-encoded in X-Shopify-Hmac-Sha256. Raw
means raw, before any JSON decoding, before any middleware touches it.
Shopify's automated App Store review sends a deliberately mis-signed
webhook and requires a 401.
public function verifyWebhook(string $rawBody, string $hmacHeader): bool
{
if ('' === $hmacHeader) {
return false;
}
$expected = base64_encode(hash_hmac('sha256', $rawBody, $this->apiSecret, true));
return hash_equals($expected, $hmacHeader);
}
The second signature type is the sharp one. Links from the Shopify admin
carry an hmac query parameter computed over the sorted query string,
and the message Shopify signs is not a URL-encoded query string. Only
&, % and = are escaped, and only in the places shown below.
Reaching for http_build_query() here produces a signature that is
wrong for any value containing a space or a slash, which is exactly the
kind of bug that passes every test you thought to write:
$pairs = [];
foreach ($query as $key => $value) {
$pairs[] = strtr($key, ['&' => '%26', '%' => '%25', '=' => '%3D'])
.'='
.strtr($value, ['&' => '%26', '%' => '%25']);
}
$expected = hash_hmac('sha256', implode('&', $pairs), $this->apiSecret);
Two things the App Store checks that are pure infrastructure
Access tokens encrypted at rest. A shop's access token is a credential for someone else's business. Storing it in plaintext means a read-only SQL injection anywhere in your app hands over every merchant's store. Libsodium makes this eight lines, and PHP ships it:
// src/Shopify/TokenCipher.php — XSalsa20-Poly1305 secretbox
public function encrypt(string $plaintext): string
{
$nonce = random_bytes(\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
return base64_encode($nonce.sodium_crypto_secretbox($plaintext, $nonce, $this->key));
}
The key is 32 random bytes, base64-encoded, in an environment variable, and the constructor rejects anything else at boot rather than at the first decrypt.
A frame-ancestors CSP scoped to the current shop. Your app must be
frameable by the merchant's admin and by nobody else, which means the
header is computed per request from the shop parameter, not set once
in the vhost:
// src/EventListener/EmbeddedAppHeadersListener.php
$frameAncestors = ShopDomain::isValid($shop)
? \sprintf('frame-ancestors https://%s https://admin.shopify.com;', $shop)
: "frame-ancestors 'none';";
$event->getResponse()->headers->set('Content-Security-Policy', $frameAncestors);
Deny-all on the fallback is the correct default: a request without a
valid shop is not an admin iframe, and it should not be framed at all.
That request is also your public landing page: the app URL registered in
the Partner Dashboard is the address a crawler or a curious merchant
reaches from a link outside the admin, and serving them the App Bridge
shell shows an inert blank page. Branch on the shop parameter and
render marketing HTML instead.
One more App Store requirement that is easy to miss in a Twig layout:
App Bridge must be the first script in <head>, loaded synchronously
from Shopify's CDN, never bundled, never deferred, on every embedded
page.
The Symfony bug that this app found, which has nothing to do with Shopify
Worth knowing whoever you build for. This email subject line lost its first two words in production, silently:
digest.attachment: 'Attached: your restock list (%count% items).'
It rendered as "your restock list (12 items)." because
%count% is numeric, which routes the string through Symfony's
pluralization logic. There, in
symfony/translation-contracts/TranslatorTrait.php, each part is tested
against '/^\w+\:\s*(.*?)$/' — an explicit-interval syntax for keyed
plural rules — and a message that innocently begins with a word followed
by a colon matches. The prefix is consumed as if it were a rule name. No
exception, no log line, just a shorter sentence.
Two lessons, one specific and one general. Specific: with %count% in a
message, never start it with Word:. General, and the more expensive
one, is why the test suite missed it. The assertion checked
assertStringContainsString('restock list', $subject) — it started
matching in the middle of the sentence, so it could only ever have
verified the part that never breaks. Assert a string from its first
character. The fix now also walks both translation catalogues and fails
CI on any %count% message matching that pattern.
What this adds up to
An embedded Shopify app in Symfony is roughly 400 lines of security code you cannot copy from the docs: a JWT verifier, a token exchanger with refresh-token rotation, a stateless authenticator, an HMAC verifier with two algorithms, an encryption wrapper, and a response listener. After that, it is a Symfony app like any other, with Doctrine, Messenger for the background sync, and the same testing tools you already use. The platform-specific surface is small and it stays where you put it.
The app that produced this code is StockPilot, low-stock alerts for Shopify merchants, live in the App Store, built on ShipAnvil with its auth, billing, admin and deploy pipeline already in place. If you are weighing an inventory tool because of the Stocky shutdown on 31 August 2026, that page is an honest map of which replacement covers which part, including the parts StockPilot does not do.
For the Symfony foundations under all of this, start with the production VPS deploy and Symfony 7.4 LTS support math.