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
| # | Flow | Who initiates | Credential | Signature / proof | Lifetime |
|---|---|---|---|---|---|
| 1 | App install | Merchant clicks Install | Admin access token (online + offline) | Token exchange of a session JWT | Online ~24h · Offline durable |
| 2 | Embedded admin | App Bridge (every fetch) | Session token (JWT) | HS256 signed with app secret | ~60 seconds |
| 3 | Webhooks | Shopify → your server | None (you verify them) | HMAC-SHA256 over the raw body, base64 | Per-request |
| 4 | Shopper sign-in | Customer in the storefront | Customer Account token | OAuth 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 adminThe 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:
- It must be
application/x-www-form-urlencoded. Send JSON and Shopify silently degrades to the legacy code-exchange path — which then fails in a way that looks unrelated. - The exchange itself enforces trust. A
shop+id_tokenmismatch is rejected by Shopify, so even a hand-shared install link without anhmaccan’t be used to mint a token for the wrong shop.
Online vs offline tokens — the single most confusing part
| Online token | Offline token | |
|---|---|---|
| Tied to | The active merchant session | The shop itself |
| Lifetime | ~24h, refreshed by App Bridge each page load | Durable |
| Use it for | Merchant-present actions (install, paywall click, widget toggle) | Background work with no merchant present |
| Caching | Never cached — each exchange mints a fresh one | Cached 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.
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:
exp/nbf— 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.)destclaim — carries the shop’s admin URL. Pull the host out and confirm it’s a*.myshopify.comshop, or you’ll happily run Admin queries against the wrong shop with a perfectly valid signature.
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-Sha256The two-HMAC trap
Shopify uses HMAC in two different shapes, and mixing them up produces the maddening “everything fails HMAC” log:
| OAuth/install callback | Webhooks | |
|---|---|---|
| Signs | Query parameters (sorted, &-joined) | The raw request body |
| Output | Hex | Base64 |
| Header / param | hmac | X-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:
- Generate a high-entropy random
code_verifier(held in memory only). - Send its SHA-256 hash as
code_challengeto the authorize endpoint. - Echo the raw
code_verifierback during token exchange. - 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/tokenPopup, 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:
event.origincheck — the strongest defense against a malicious co-tenant page forging a callback.statenonce match — a random value round-tripped through the authorize request; a mismatch means possible CSRF, so refuse it.- No secret in the exchange —
code_verifieris the proof of identity.
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:
- The app client secret is your master key. It signs install JWTs, validates session tokens, and verifies webhook HMACs. Guard it like a root password.
- HMAC = “is this really from Shopify?” Two shapes (query/hex vs body/base64). Always constant-time compare. Webhooks: verify the raw body.
- PKCE = “auth without a secret” for any public client (storefront widgets, SPAs, mobile).
statenonce = CSRF protection for any redirect-based flow.- Online vs offline tokens answer “is the merchant present right now?”
- Encrypt every token at rest. A storefront token is admin access to a real business.
Quick reference: which auth for which job?
| You’re building… | Use |
|---|---|
| App install / OAuth handshake | Managed install + token exchange (Flow 1) |
| Calls from your embedded admin UI | App Bridge session tokens (Flow 2) |
| Reacting to store events (orders, uninstall, GDPR) | Webhook HMAC verification (Flow 3) |
| Letting a shopper see their own orders/account | Customer Account API + PKCE (Flow 4) |
| Background jobs with no merchant present | The 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.