<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Wes W</title>
    <description>The latest articles on DEV Community by Wes W (@wes_w_59e9f26bd6dfdd82553).</description>
    <link>https://dev.to/wes_w_59e9f26bd6dfdd82553</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3996773%2F43187740-8553-41f6-a657-2ed989f0533f.png</url>
      <title>DEV Community: Wes W</title>
      <link>https://dev.to/wes_w_59e9f26bd6dfdd82553</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wes_w_59e9f26bd6dfdd82553"/>
    <language>en</language>
    <item>
      <title>How Shopify Auth Actually Works (4 Flows, One App)</title>
      <dc:creator>Wes W</dc:creator>
      <pubDate>Mon, 13 Jul 2026 16:41:21 +0000</pubDate>
      <link>https://dev.to/wes_w_59e9f26bd6dfdd82553/how-shopify-auth-actually-works-4-flows-one-app-5bi8</link>
      <guid>https://dev.to/wes_w_59e9f26bd6dfdd82553/how-shopify-auth-actually-works-4-flows-one-app-5bi8</guid>
      <description>&lt;p&gt;"Shopify auth" isn't one thing. A real Shopify app authenticates in &lt;strong&gt;four different ways&lt;/strong&gt;, each with its own token, its own signature scheme, and its own failure mode. Mix them up and you get the classic symptoms: &lt;em&gt;all webhooks fail HMAC&lt;/em&gt;, &lt;em&gt;the embedded admin shows a blank iframe&lt;/em&gt;, &lt;em&gt;guest order lookup 403s forever&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is the map we wish we'd had when we built WisWes's Shopify integration — written from the production code, not the marketing diagrams.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four flows at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Flow&lt;/th&gt;
&lt;th&gt;Who initiates&lt;/th&gt;
&lt;th&gt;Credential&lt;/th&gt;
&lt;th&gt;Signature / proof&lt;/th&gt;
&lt;th&gt;Lifetime&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;App install&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Merchant clicks &lt;em&gt;Install&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;Admin access token (online + offline)&lt;/td&gt;
&lt;td&gt;Token exchange of a session JWT&lt;/td&gt;
&lt;td&gt;Online ~24h · Offline durable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Embedded admin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;App Bridge (every fetch)&lt;/td&gt;
&lt;td&gt;Session token (JWT)&lt;/td&gt;
&lt;td&gt;HS256 signed with app secret&lt;/td&gt;
&lt;td&gt;~60 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Webhooks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Shopify → your server&lt;/td&gt;
&lt;td&gt;None (you verify them)&lt;/td&gt;
&lt;td&gt;HMAC-SHA256 over the &lt;strong&gt;raw body&lt;/strong&gt;, base64&lt;/td&gt;
&lt;td&gt;Per-request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Shopper sign-in&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Customer in the storefront&lt;/td&gt;
&lt;td&gt;Customer Account token&lt;/td&gt;
&lt;td&gt;OAuth 2.0 + &lt;strong&gt;PKCE&lt;/strong&gt; (no secret)&lt;/td&gt;
&lt;td&gt;~1h, refreshable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The thing to internalize: &lt;strong&gt;flows 1–3 authenticate the merchant/app; flow 4 authenticates a shopper.&lt;/strong&gt; They never share a token.&lt;/p&gt;




&lt;h2&gt;
  
  
  Flow 1 — App install (managed install + token exchange)
&lt;/h2&gt;

&lt;p&gt;Modern Shopify apps use &lt;strong&gt;managed install&lt;/strong&gt;. The merchant clicks &lt;em&gt;Install&lt;/em&gt; in the App Store, Shopify shows &lt;em&gt;its own&lt;/em&gt; consent screen, and then redirects to your app URL with an &lt;code&gt;id_token&lt;/code&gt; in the query string. You never see an OAuth &lt;code&gt;code&lt;/code&gt; and you never run a Redis state-machine — that whole legacy code-exchange dance is gone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Merchant → App Store "Install"
        → Shopify consent screen (Shopify-hosted)
        → GET /install?shop=…&amp;amp;id_token=…&amp;amp;host=…&amp;amp;hmac=…
              │
              ├─ verify HMAC (if present)
              ├─ token-exchange id_token → ONLINE access token
              ├─ fetch shop.json (name, email, plan, shop_id)
              ├─ provision tenant, encrypt + persist token
              ├─ token-exchange id_token → OFFLINE access token
              ├─ create storefront script_tag
              └─ register webhooks → redirect into embedded admin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The &lt;code&gt;id_token&lt;/code&gt; is a session JWT
&lt;/h3&gt;

&lt;p&gt;It's signed with &lt;strong&gt;your app's client secret&lt;/strong&gt;. The token exchange swaps it for a real Admin API access token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;client_secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grant_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:ietf:params:oauth:grant-type:token-exchange&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject_token_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:ietf:params:oauth:token-type:id_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requested_token_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;requested_token_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# online or offline
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas that cost real debugging hours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It must be &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;.&lt;/strong&gt; Send JSON and Shopify silently degrades to the legacy code-exchange path — which then fails in a way that looks unrelated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The exchange itself enforces trust.&lt;/strong&gt; A &lt;code&gt;shop&lt;/code&gt; + &lt;code&gt;id_token&lt;/code&gt; mismatch is rejected by Shopify, so even a hand-shared install link without an &lt;code&gt;hmac&lt;/code&gt; can't be used to mint a token for the wrong shop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Online vs offline tokens — the single most confusing part
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Online token&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Offline token&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tied to&lt;/td&gt;
&lt;td&gt;The active merchant session&lt;/td&gt;
&lt;td&gt;The shop itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifetime&lt;/td&gt;
&lt;td&gt;~24h, refreshed by App Bridge each page load&lt;/td&gt;
&lt;td&gt;Durable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use it for&lt;/td&gt;
&lt;td&gt;Merchant-present actions (install, paywall click, widget toggle)&lt;/td&gt;
&lt;td&gt;Background work with no merchant present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caching&lt;/td&gt;
&lt;td&gt;Never cached — each exchange mints a fresh one&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Cached by Shopify per (app, shop)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You exchange the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;id_token&lt;/code&gt; twice — once per type — because they do different jobs. The widget toggle from your own dashboard has no App Bridge session, so it leans on the cached offline token. The catalog indexer and guest order lookup &lt;em&gt;only&lt;/em&gt; work with the offline token.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The poisoned-token trap.&lt;/strong&gt; Shopify caches offline tokens per (app, shop). A shop that &lt;em&gt;ever&lt;/em&gt; held a legacy non-expiring offline token keeps getting it back — it returns &lt;code&gt;expires_in=0&lt;/code&gt; and then 403s on every Admin call. The only fix is &lt;strong&gt;uninstall → reinstall&lt;/strong&gt; to clear Shopify's cached grant. Defensive rule: treat &lt;code&gt;expires_in == 0&lt;/code&gt; as "no usable token" and refuse to persist it, so one bad token doesn't clobber a previously-good one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Encrypt the token at rest
&lt;/h3&gt;

&lt;p&gt;A shop-scoped admin token is &lt;strong&gt;admin write access to the merchant's store&lt;/strong&gt;. Leaking it is catastrophic. Encrypt it (we use a Fernet key) before it touches the database — never store the plaintext alongside the ciphertext.&lt;/p&gt;




&lt;h2&gt;
  
  
  Flow 2 — Embedded admin (App Bridge session tokens)
&lt;/h2&gt;

&lt;p&gt;Your app's admin UI runs inside an iframe in Shopify admin. That iframe &lt;strong&gt;can't use cookies&lt;/strong&gt; — Shopify admin blocks third-party cookies, so your normal cookie-session login can't reach you inside the frame.&lt;/p&gt;

&lt;p&gt;Instead, &lt;strong&gt;App Bridge mints a fresh JWT roughly every minute&lt;/strong&gt; and attaches it to every &lt;code&gt;fetch()&lt;/code&gt; as &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;. You validate it on the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SHOPIFY_APP_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# symmetric — same secret signs and verifies
&lt;/span&gt;    &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SHOPIFY_APP_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;leeway&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;      &lt;span class="c1"&gt;# tolerate a few seconds of NTP drift
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signature is the load-bearing check: forging a token would require your app secret. Then two more gates matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;exp&lt;/code&gt; / &lt;code&gt;nbf&lt;/code&gt;&lt;/strong&gt; — the token is valid for only ~60 seconds, so a stolen one dies fast. (Allow ~5–10s leeway; pod clocks drift and a false 401 looks like an auth bug.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dest&lt;/code&gt; claim&lt;/strong&gt; — carries the shop's admin URL. Pull the host out and confirm it's a &lt;code&gt;*.myshopify.com&lt;/code&gt; shop, or you'll happily run Admin queries against the &lt;em&gt;wrong&lt;/em&gt; shop with a perfectly valid signature.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't bother replay-protecting the &lt;code&gt;jti&lt;/code&gt; nonce — the 60s window is short enough that the Redis storage isn't worth it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Flow 3 — Webhooks (HMAC over the raw body)
&lt;/h2&gt;

&lt;p&gt;Shopify calls &lt;em&gt;you&lt;/em&gt;. You can't trust the caller, so every webhook is signed. Verification is non-negotiable — the GDPR compliance webhooks (&lt;code&gt;customers/redact&lt;/code&gt;, &lt;code&gt;shop/redact&lt;/code&gt;, &lt;code&gt;customers/data_request&lt;/code&gt;) are part of App Store review, and a single non-2xx within 5 seconds rejects your submission.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;header_hmac&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# X-Shopify-Hmac-Sha256
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The two-HMAC trap
&lt;/h3&gt;

&lt;p&gt;Shopify uses HMAC in &lt;strong&gt;two different shapes&lt;/strong&gt;, and mixing them up produces the maddening "everything fails HMAC" log:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;OAuth/install callback&lt;/th&gt;
&lt;th&gt;Webhooks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Signs&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Query parameters&lt;/strong&gt; (sorted, &lt;code&gt;&amp;amp;&lt;/code&gt;-joined)&lt;/td&gt;
&lt;td&gt;The &lt;strong&gt;raw request body&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output&lt;/td&gt;
&lt;td&gt;Hex&lt;/td&gt;
&lt;td&gt;Base64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Header / param&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hmac&lt;/code&gt; query param&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X-Shopify-Hmac-Sha256&lt;/code&gt; header&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The webhook killer: &lt;strong&gt;verify against the raw bytes&lt;/strong&gt;, before any JSON parsing. Re-serializing the payload changes key ordering and whitespace, which silently breaks the comparison. In FastAPI that means &lt;code&gt;await request.body()&lt;/code&gt; — not the parsed model.&lt;/p&gt;

&lt;p&gt;Always use a &lt;strong&gt;constant-time compare&lt;/strong&gt; (&lt;code&gt;hmac.compare_digest&lt;/code&gt;) so you don't leak the signature one byte at a time through timing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Flow 4 — Shopper sign-in (Customer Account API + PKCE)
&lt;/h2&gt;

&lt;p&gt;This is the one most guides skip. When a &lt;em&gt;shopper&lt;/em&gt; — not the merchant — needs to authenticate (e.g. "track my order" in a chat widget), you use Shopify's &lt;strong&gt;Customer Account API&lt;/strong&gt; with OAuth 2.0 &lt;strong&gt;PKCE&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why PKCE instead of a client secret
&lt;/h3&gt;

&lt;p&gt;The widget runs in the storefront — public, inspectable JavaScript. There is &lt;strong&gt;nowhere safe to put a client secret.&lt;/strong&gt; PKCE solves this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate a high-entropy random &lt;code&gt;code_verifier&lt;/code&gt; (held in memory only).&lt;/li&gt;
&lt;li&gt;Send its SHA-256 hash as &lt;code&gt;code_challenge&lt;/code&gt; to the authorize endpoint.&lt;/li&gt;
&lt;li&gt;Echo the raw &lt;code&gt;code_verifier&lt;/code&gt; back during token exchange.&lt;/li&gt;
&lt;li&gt;Shopify checks the verifier hashes to the challenge before minting a token.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A &lt;code&gt;code&lt;/code&gt; leaked from the redirect URL is &lt;strong&gt;useless&lt;/strong&gt; without the verifier the widget never transmitted until the final exchange.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;authorize → https://shopify.com/authentication/{shop_id}/oauth/authorize
token     → https://shopify.com/authentication/{shop_id}/oauth/token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Popup, not full-page redirect
&lt;/h3&gt;

&lt;p&gt;A chat widget lives inside a host page; a full-page redirect would destroy the conversation. So the flow opens a &lt;strong&gt;popup&lt;/strong&gt; for consent, the popup &lt;code&gt;postMessage&lt;/code&gt;s the auth code back, and the parent — which holds the verifier — finishes the exchange in-page.&lt;/p&gt;

&lt;p&gt;Three security gates on the popup callback:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;event.origin&lt;/code&gt; check&lt;/strong&gt; — the strongest defense against a malicious co-tenant page forging a callback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;state&lt;/code&gt; nonce match&lt;/strong&gt; — a random value round-tripped through the authorize request; a mismatch means possible CSRF, so refuse it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No secret in the exchange&lt;/strong&gt; — &lt;code&gt;code_verifier&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the proof of identity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Token storage and refresh
&lt;/h3&gt;

&lt;p&gt;Tokens live in &lt;code&gt;localStorage&lt;/code&gt;, &lt;strong&gt;keyed by shop domain&lt;/strong&gt; so a browser used at two stores doesn't cross-contaminate. The widget treats the access token as opaque — only Shopify's MCP server ever validates it. On a 401, drop the cached token, try exactly one refresh-and-retry, then fall back to a fresh sign-in.&lt;/p&gt;




&lt;h2&gt;
  
  
  The security primitives, summarized
&lt;/h2&gt;

&lt;p&gt;Every Shopify auth flow is built from the same handful of pieces. Learn these and the four flows stop feeling like four unrelated systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The app client secret is your master key.&lt;/strong&gt; It signs install JWTs, validates session tokens, and verifies webhook HMACs. Guard it like a root password.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HMAC = "is this really from Shopify?"&lt;/strong&gt; Two shapes (query/hex vs body/base64). Always constant-time compare. Webhooks: verify the &lt;em&gt;raw&lt;/em&gt; body.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PKCE = "auth without a secret"&lt;/strong&gt; for any public client (storefront widgets, SPAs, mobile).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;state&lt;/code&gt; nonce = CSRF protection&lt;/strong&gt; for any redirect-based flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Online vs offline tokens&lt;/strong&gt; answer "is the merchant present right now?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypt every token at rest.&lt;/strong&gt; A storefront token is admin access to a real business.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Quick reference: which auth for which job?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You're building…&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App install / OAuth handshake&lt;/td&gt;
&lt;td&gt;Managed install + token exchange (Flow 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calls from your embedded admin UI&lt;/td&gt;
&lt;td&gt;App Bridge session tokens (Flow 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reacting to store events (orders, uninstall, GDPR)&lt;/td&gt;
&lt;td&gt;Webhook HMAC verification (Flow 3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Letting a shopper see their own orders/account&lt;/td&gt;
&lt;td&gt;Customer Account API + PKCE (Flow 4)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background jobs with no merchant present&lt;/td&gt;
&lt;td&gt;The &lt;strong&gt;offline&lt;/strong&gt; admin token (Flow 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on the &lt;a href="https://wiswes.com/blog/how-shopify-auth-works" rel="noopener noreferrer"&gt;WisWes blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://wiswes.com" rel="noopener noreferrer"&gt;WisWes&lt;/a&gt; is an AI sales assistant for Shopify, Shopware and Magento. The flows above are exactly how it authenticates in production — from the one-click install to a shopper signing in to track an order.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>security</category>
      <category>webdev</category>
      <category>api</category>
    </item>
  </channel>
</rss>
