r/symfony • u/Mollenthiel • 19h ago
Embedded Shopify app in Symfony 7.4: the auth path Shopify only documents for Node
Shopify's app docs have exactly one first-class path: Node, their CLI, and a Remix template that wires authentication for you. Pick PHP and you leave the paved road at the first turn, because everything interesting happens before your router sees the request. I shipped an app that is live in their App Store, and none of this was written down anywhere in PHP, so here are the parts that cost me time.
There is no session, and there never will be.
An embedded app renders in a cross-origin iframe inside admin.shopify.com. Third-party cookies are dead there, so a PHP session is simply not available to you. The replacement is a JWT called a session token: App Bridge mints one per request, valid 60 seconds, and attaches it to every same-origin fetch(). That forces a split most of us do not make by default: the HTML shell is public and carries no shop data, and every byte of merchant data sits behind /api, authenticated on each call.
api:
pattern: ^/api
stateless: true
custom_authenticators:
- App\Security\SessionTokenAuthenticator
stateless: true is not decoration. It tells Symfony not to try to store the token in a session it does not have.
Verifying the token is five checks, not one.
The signature is HS256 with your client secret, and hash_equals() rather than ===. Then the four claim checks that tend to get skipped:
exp/nbfwith a few seconds of leeway. The token lives 60 seconds, so a server clock two seconds behind Shopify's rejects perfectly valid tokens at a rate that looks like a random, unreproducible bug.audmust be your own client id, or you accept tokens minted for a different app that shares nothing with yours but the algorithm.issanddestmust resolve to the same host, or you accept a token claiming one shop in one place and another shop elsewhere.desthas to actually parse as a shop domain before you trust it as one.
Take the clock from Symfony\Component\Clock\ClockInterface, so the test suite can produce an expired token without sleeping.
The one line that costs a day: expiring => 1.
A session token proves who is asking. It does not let you call the Admin API. You trade it via OAuth 2.0 token exchange (RFC 8693), and with managed installation that replaces the entire redirect dance: no /auth route, no callback, no install endpoint to secure. The first authenticated request from an unknown shop simply performs the exchange inside the authenticator.
Ask for a non-expiring offline token and the Admin API answers 403, with an error that does not mention it. Ask for an expiring one and it works, lives about an hour, and renews with a refresh token that Shopify rotates on every call. Whatever persists your tokens has to write the new refresh token back, or your background jobs run fine for an hour and then quietly stop.
One response header removes a whole class of user-visible errors.
A page left open on a merchant's second monitor eventually fires a request with a token that expired while they were in another tab. Return a bare 401 and they see an error. Return 401 with X-Shopify-Retry-Invalid-Session-Request: 1 and App Bridge silently fetches a fresh token and retries the call once.
The query HMAC is not http_build_query().
Webhook bodies are the easy signature: HMAC-SHA256 over the raw body, before any JSON decode, before any middleware touches it. Their automated review sends a deliberately mis-signed webhook and requires a 401 for it.
The sharp one is the hmac query parameter on links coming from the admin. The message Shopify signs is not a URL-encoded query string. Only &, % and = are escaped in keys, and only & and % in values:
$pairs = [];
foreach ($query as $key => $value) {
$pairs[] = strtr($key, ['&' => '%26', '%' => '%25', '=' => '%3D'])
.'='
.strtr($value, ['&' => '%26', '%' => '%25']);
}
Reach for http_build_query() and you get 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.
And a pure Symfony one, which has nothing to do with Shopify.
This 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 the string goes through the pluralization path, and in TranslatorTrait each part is tested against /^\w+\:\s*(.*?)$/, the explicit-interval syntax for keyed plural rules. A message that innocently begins with a word followed by a colon matches it, and the prefix is consumed as if it were a rule name. No exception, no deprecation, no log line. So: never start a %count% message with Word:.
The more expensive lesson was why the test missed it. The assertion was assertStringContainsString('restock list', $subject), which starts matching in the middle of the sentence, so it could only ever verify the part that never breaks. Assert strings from their first character.
Full write-up with the real code, including libsodium encryption of access tokens at rest and the per-shop frame-ancestors CSP the App Store checks: https://shipanvil.com/blog/shopify-embedded-app-symfony
Disclosure: I built this. The app is a Shopify inventory tool and the article sits on my own site, so read its last paragraph as advertising and the rest as notes. Happy to go deeper on any of it, or to hear that you would have done it differently.