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 }
Enter fullscreen mode Exit fullscreen mode
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.');
}
Enter fullscreen mode Exit fullscreen mode
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,
]);
}
Enter fullscreen mode Exit fullscreen mode
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'],
);
}
Enter fullscreen mode Exit fullscreen mode
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);
}
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode
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));
}
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode
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).'
Enter fullscreen mode Exit fullscreen mode
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.
답글 남기기