← All articles
10 min read

How Shopify Auth Actually Works (4 Flows, One App)

“Shopify auth” isn’t one thing. A real Shopify app authenticates in four different ways, each with its own token, its own signature scheme, and its own failure mode. Mix them up and you get the classic symptoms: all webhooks fail HMAC, the embedded admin shows a blank iframe, guest order lookup 403s forever.

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.

The four flows at a glance

#FlowWho initiatesCredentialSignature / proofLifetime
1App installMerchant clicks InstallAdmin access token (online + offline)Token exchange of a session JWTOnline ~24h · Offline durable
2Embedded adminApp Bridge (every fetch)Session token (JWT)HS256 signed with app secret~60 seconds
3WebhooksShopify → your serverNone (you verify them)HMAC-SHA256 over the raw body, base64Per-request
4Shopper sign-inCustomer in the storefrontCustomer Account tokenOAuth 2.0 + PKCE (no secret)~1h, refreshable

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

Flow 1 — App install (managed install + token exchange)

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

Merchant → App Store "Install"
        → Shopify consent screen (Shopify-hosted)
        → GET /install?shop=…&id_token=…&host=…&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

The id_token is a session JWT

It’s signed with your app’s client secret. The token exchange swaps it for a real Admin API access token:

body = {
    "client_id": client_id,
    "client_secret": client_secret,
    "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
    "subject_token": id_token,
    "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
    "requested_token_type": requested_token_type,  # online or offline
}

Two gotchas that cost real debugging hours:

Online vs offline tokens — the single most confusing part

Online tokenOffline token
Tied toThe active merchant sessionThe shop itself
Lifetime~24h, refreshed by App Bridge each page loadDurable
Use it forMerchant-present actions (install, paywall click, widget toggle)Background work with no merchant present
CachingNever cached — each exchange mints a fresh oneCached by Shopify per (app, shop)

You exchange the same id_token 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 only work with the offline token.

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

Encrypt the token at rest

A shop-scoped admin token is admin write access to the merchant’s store. Leaking it is catastrophic. Encrypt it (we use a Fernet key) before it touches the database — never store the plaintext alongside the ciphertext.

Flow 2 — Embedded admin (App Bridge session tokens)

Your app’s admin UI runs inside an iframe in Shopify admin. That iframe can’t use cookies — Shopify admin blocks third-party cookies, so your normal cookie-session login can’t reach you inside the frame.

Instead, App Bridge mints a fresh JWT roughly every minute and attaches it to every fetch() as Authorization: Bearer <token>. You validate it on the server:

claims = jwt.decode(
    token,
    SHOPIFY_APP_CLIENT_SECRET,   # symmetric — same secret signs and verifies
    algorithms=["HS256"],
    audience=SHOPIFY_APP_CLIENT_ID,
    options={"leeway": 10},      # tolerate a few seconds of NTP drift
)

The signature is the load-bearing check: forging a token would require your app secret. Then two more gates matter:

Don’t bother replay-protecting the jti nonce — the 60s window is short enough that the Redis storage isn’t worth it.

Flow 3 — Webhooks (HMAC over the raw body)

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

digest = hmac.new(secret.encode(), raw_body, sha256).digest()
expected = base64.b64encode(digest).decode()
return hmac.compare_digest(expected, header_hmac)  # X-Shopify-Hmac-Sha256

The two-HMAC trap

Shopify uses HMAC in two different shapes, and mixing them up produces the maddening “everything fails HMAC” log:

OAuth/install callbackWebhooks
SignsQuery parameters (sorted, &-joined)The raw request body
OutputHexBase64
Header / paramhmacX-Shopify-Hmac-Sha256

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

Always use a constant-time compare (hmac.compare_digest) so you don’t leak the signature one byte at a time through timing.

Flow 4 — Shopper sign-in (Customer Account API + PKCE)

This is the one most guides skip. When a shopper — not the merchant — needs to authenticate (e.g. “track my order” in a chat widget), you use Shopify’s Customer Account API with OAuth 2.0 PKCE.

Why PKCE instead of a client secret

The widget runs in the storefront — public, inspectable JavaScript. There is nowhere safe to put a client secret. PKCE solves this:

  1. Generate a high-entropy random code_verifier (held in memory only).
  2. Send its SHA-256 hash as code_challenge to the authorize endpoint.
  3. Echo the raw code_verifier back during token exchange.
  4. Shopify checks the verifier hashes to the challenge before minting a token.

A code leaked from the redirect URL is useless without the verifier the widget never transmitted until the final exchange.

authorize → https://shopify.com/authentication/{shop_id}/oauth/authorize
token     → https://shopify.com/authentication/{shop_id}/oauth/token

Popup, not full-page redirect

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

Three security gates on the popup callback:

Token storage and refresh

Tokens live in localStorage, keyed by shop domain 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.

The security primitives, summarized

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

Quick reference: which auth for which job?

You’re building…Use
App install / OAuth handshakeManaged install + token exchange (Flow 1)
Calls from your embedded admin UIApp Bridge session tokens (Flow 2)
Reacting to store events (orders, uninstall, GDPR)Webhook HMAC verification (Flow 3)
Letting a shopper see their own orders/accountCustomer Account API + PKCE (Flow 4)
Background jobs with no merchant presentThe offline admin token (Flow 1)

WisWes 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.

Turn questions into checkout.

WisWes drops into your store and guides shoppers from browsing to buying. 14-day free trial — no card.