Skip to content

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.