{"id":16978,"plugin_id":"plugins_6a701c7b1f9481919cf7c7448ddc1bd4","kind":"skill","collection_source":null,"comparison_source":null,"observed_at":"2026-09-30T23:13:55.288Z","digest":"c942d1157efb72454dbc49bf8ca729e3c824492d62315d4cd5f24dc8fa1ccb9b","against":null,"payload":{"description":"Implement OAuth 2.0, Token Exchange, Managed Installation, App Proxy, and webhook verification for Shopify apps. Support online/offline tokens, session storage (Prisma, Redis, Memory), and multi-auth patterns. Covers admin API, public apps, custom apps, and customer account authentication. Triggers include: 'Shopify authentication', 'OAuth 2.0', 'Token Exchange', 'Managed Installation', 'App Proxy', 'Webhook signature', 'HMAC verification', 'Admin API auth', 'Customer Account API', 'Session storage', 'Online token', 'Offline token', 'Remix auth', 'shopify auth'.","included_files":[],"name":"app-auth","skill_md_contents":"---\nname: app-auth\ndescription: \"Implement OAuth 2.0, Token Exchange, Managed Installation, App Proxy, and webhook verification for Shopify apps. Support online/offline tokens, session storage (Prisma, Redis, Memory), and multi-auth patterns. Covers admin API, public apps, custom apps, and customer account authentication. Triggers include: 'Shopify authentication', 'OAuth 2.0', 'Token Exchange', 'Managed Installation', 'App Proxy', 'Webhook signature', 'HMAC verification', 'Admin API auth', 'Customer Account API', 'Session storage', 'Online token', 'Offline token', 'Remix auth', 'shopify auth'.\"\n---\n\n# Shopify App Authentication\n\nShopify provides multiple authentication flows depending on your app type and use case. The modern standard is **Token Exchange** (2024+) for server-rendered apps, **Managed Installation** for headless apps, and **OAuth 2.0 Authorization Code Grant** for legacy/custom implementations. All flows result in an access token for the Shopify GraphQL Admin API.\n\n## Authentication Flows Overview\n\n### 1. Token Exchange (Recommended for 2024+)\n\n**Use Case:** Server-rendered apps (Remix, Next.js with SSR), Shopify CLI apps\n**Flow:** Merchant installs app → Shopify generates temporary exchange token → App exchanges for access token\n**Security:** No client secret exposed; uses PKCE-style rotation per request\n**Token Lifetime:** Access tokens are short-lived; refresh tokens rotate automatically\n\n**Token Exchange Diagram:**\n```\n1. Merchant clicks \"Install\" in Shopify Admin\n2. Shopify redirects: https://your-app.com/auth/callback?code=EXCHANGE_TOKEN\n3. App validates HMAC, exchanges code for access token (private, server-side only)\n4. Shopify Admin API grants scopes; token stored in session/database\n5. Token auto-refreshes on next request if expired\n```\n\n**Remix Implementation (Token Exchange):**\n\n```typescript\n// shopify.app.ts (App Configuration)\nimport { shopifyApp } from '@shopify/shopify-app-remix/server';\nimport { restResources } from '@shopify/shopify-api/rest/admin/2026-07';\nimport { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';\nimport { prisma } from '~/db.server';\n\nconst shopify = shopifyApp({\n  apiKey: process.env.SHOPIFY_API_KEY || '',\n  apiSecret: process.env.SHOPIFY_API_SECRET || '',\n  scopes: process.env.SCOPES?.split(',') || [\n    'write_products',\n    'read_products',\n    'write_orders',\n    'read_orders',\n    'write_customers',\n    'read_customers',\n    'write_discounts',\n    'read_discounts',\n    'write_fulfillments',\n    'read_fulfillments',\n    'write_inventory',\n    'read_inventory',\n  ],\n  appUrl: process.env.SHOPIFY_APP_URL || 'http://localhost:3000',\n  auth: {\n    path: '/auth',\n    callbackPath: '/auth/callback',\n  },\n  webhooks: {\n    path: '/webhooks',\n  },\n  isEmbeddedApp: true, // Polaris admin dashboard\n  sessionStorage: new PrismaSessionStorage(prisma),\n  restResources, // Includes REST API helpers\n});\n\nexport default shopify;\n```\n\n**Environment Variables (.env):**\n```bash\nSHOPIFY_API_KEY=your-public-api-key-from-partner-dashboard\nSHOPIFY_API_SECRET=your-private-api-secret\nSHOPIFY_APP_URL=https://your-domain.ngrok.io  # or prod URL\nSCOPES=write_products,read_products,write_orders,read_orders\n```\n\n**Auth Routes (routes/auth.$.tsx - Catch-all route):**\n```typescript\nimport { redirect } from '@shopify/remix-oxygen';\nimport { authenticate } from '~/shopify.server';\n\nexport const loader = async ({ request }) => {\n  const { authenticate } = await import('~/shopify.server');\n  return authenticate.admin(request); // Token Exchange happens here\n};\n```\n\n**Callback Handling (routes/auth.callback.tsx):**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport { authenticate } from '~/shopify.server';\n\nexport const loader = async ({ request }) => {\n  const { session } = await authenticate.admin(request);\n  // Session contains:\n  // - session.accessToken (valid for API calls)\n  // - session.shop (merchant's shop domain)\n  // - session.scope (granted scopes)\n  // - session.state (optional custom data)\n\n  return redirect('/app'); // Redirect to dashboard after auth\n};\n```\n\n**Using Token in API Calls (routes/app.products.tsx):**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport { authenticate } from '~/shopify.server';\nimport { GraphQLClient } from 'graphql-request';\n\nexport const loader = async ({ request }) => {\n  const { session, admin } = await authenticate.admin(request);\n\n  // Method 1: Use Shopify's admin helper (recommended)\n  const response = await admin.graphql(`\n    query GetProducts {\n      products(first: 10) {\n        edges {\n          node {\n            id\n            title\n            handle\n          }\n        }\n      }\n    }\n  `);\n\n  // Method 2: Manual GraphQL call\n  const client = new GraphQLClient(\n    `https://${session.shop}/admin/api/2026-07/graphql.json`,\n    {\n      headers: {\n        'X-Shopify-Access-Token': session.accessToken,\n        'Content-Type': 'application/json',\n      },\n    }\n  );\n\n  const data = await client.request(/* ... */);\n\n  return json({ products: response.data?.products?.edges || [] });\n};\n```\n\n**Token Refresh (Automatic):**\nToken Exchange tokens auto-refresh via Shopify's session middleware. No manual refresh needed:\n```typescript\n// Tokens are refreshed transparently on each request\nconst response = await admin.graphql(query); // Handles refresh internally\n```\n\n### 2. Managed Installation (Headless/Custom Apps)\n\n**Use Case:** Headless storefront, mobile apps, third-party integrations\n**Flow:** Merchant authorizes app → Shopify generates permanent access token (no secret rotation)\n**Token Lifetime:** Long-lived; no refresh required\n**Security:** Token is permanent; store securely in environment variable\n\n**Managed Installation Setup:**\n\nIn Shopify Partner Dashboard:\n1. App Settings > API Credentials\n2. Select \"Managed installation\" under Admin API access scopes\n3. Merchant grants permission once\n4. Copy access token to your environment\n\n```bash\n# .env\nSHOPIFY_ADMIN_ACCESS_TOKEN=<SHOPIFY_ADMIN_ACCESS_TOKEN>\nSHOPIFY_SHOP_URL=example-shop.myshopify.com\n```\n\n**Using Managed Installation Token:**\n```typescript\nimport { GraphQLClient } from 'graphql-request';\n\nconst client = new GraphQLClient(\n  `https://${process.env.SHOPIFY_SHOP_URL}/admin/api/2026-07/graphql.json`,\n  {\n    headers: {\n      'X-Shopify-Access-Token': process.env.SHOPIFY_ADMIN_ACCESS_TOKEN || '',\n    },\n  }\n);\n\n// Token never expires; call API anytime\nconst query = `query { products(first: 10) { edges { node { id title } } } }`;\nconst products = await client.request(query);\n```\n\n### 3. OAuth 2.0 Authorization Code Grant (Legacy, Still Supported)\n\n**Use Case:** Public apps with traditional OAuth flow\n**Flow:** Merchant clicks \"Install\" → App redirects to Shopify OAuth → Merchant authorizes → App receives code → App exchanges code for token\n**Token Lifetime:** Long-lived access token (no expiration unless revoked)\n**Security:** Client secret required; PKCE optional\n\n**OAuth Flow Diagram:**\n```\n1. Merchant visits: https://your-app.com/auth\n2. App redirects to: https://your-shop.myshopify.com/admin/oauth/authorize?client_id=KEY&scope=write_products&redirect_uri=https://your-app.com/auth/callback&state=RANDOM\n3. Merchant authorizes app in Shopify Admin\n4. Shopify redirects back: https://your-app.com/auth/callback?code=AUTHORIZATION_CODE&hmac=SIGNATURE&state=RANDOM&shop=your-shop.myshopify.com\n5. App validates HMAC and state\n6. App exchanges code for token (server-side, using client secret)\n7. App stores token in database\n```\n\n**Express.js OAuth Example:**\n```typescript\nimport express from 'express';\nimport axios from 'axios';\nimport crypto from 'crypto';\n\nconst app = express();\n\nconst API_KEY = process.env.SHOPIFY_API_KEY || '';\nconst API_SECRET = process.env.SHOPIFY_API_SECRET || '';\nconst REDIRECT_URI = process.env.REDIRECT_URI || 'https://your-app.com/auth/callback';\nconst SCOPES = 'write_products,read_products';\n\n// Step 1: Redirect merchant to Shopify OAuth\napp.get('/auth', (req, res) => {\n  const shop = req.query.shop as string;\n\n  if (!shop || !shop.includes('.myshopify.com')) {\n    return res.status(400).send('Missing or invalid shop parameter');\n  }\n\n  const state = crypto.randomBytes(16).toString('hex');\n  const nonce = crypto.randomBytes(16).toString('hex');\n\n  // Store state in session (or database) for validation\n  req.session.state = state;\n  req.session.nonce = nonce;\n\n  const authUrl = new URL(\n    `/admin/oauth/authorize`,\n    `https://${shop}`\n  );\n  authUrl.searchParams.append('client_id', API_KEY);\n  authUrl.searchParams.append('scope', SCOPES);\n  authUrl.searchParams.append('redirect_uri', REDIRECT_URI);\n  authUrl.searchParams.append('state', state);\n\n  res.redirect(authUrl.toString());\n});\n\n// Step 2: Handle OAuth callback\napp.get('/auth/callback', async (req, res) => {\n  const { code, hmac, shop, state } = req.query;\n\n  // Validate HMAC\n  const message = Object.entries(req.query)\n    .filter(([key]) => key !== 'hmac')\n    .map(([key, value]) => `${key}=${value}`)\n    .sort()\n    .join('&');\n\n  const hash = crypto\n    .createHmac('sha256', API_SECRET)\n    .update(message, 'utf8')\n    .digest('base64');\n\n  if (hash !== hmac) {\n    return res.status(401).send('Unauthorized request detected');\n  }\n\n  // Validate state\n  if (state !== req.session.state) {\n    return res.status(401).send('State mismatch');\n  }\n\n  try {\n    // Exchange code for access token\n    const response = await axios.post(\n      `https://${shop}/admin/oauth/access_token`,\n      {\n        client_id: API_KEY,\n        client_secret: API_SECRET,\n        code,\n      }\n    );\n\n    const { access_token, scope } = response.data;\n\n    // Store access token (in database, not session, for persistence)\n    await storeAccessToken(shop as string, access_token, scope);\n\n    // Redirect to app dashboard\n    res.redirect(`/app?shop=${shop}`);\n  } catch (error) {\n    console.error('Token exchange error:', error);\n    res.status(500).send('Authentication failed');\n  }\n});\n\n// Helper function to store token\nasync function storeAccessToken(shop: string, token: string, scope: string) {\n  // Store in database (example using mock storage)\n  const db = {\n    shops: {} as Record<string, { token: string; scope: string }>,\n  };\n  db.shops[shop] = { token, scope };\n}\n\napp.listen(3000);\n```\n\n## Session Storage Adapters\n\nAccess tokens must be stored persistently. Shopify provides adapters for common storage backends:\n\n### Prisma (Recommended for Remix)\n\n**Schema (prisma/schema.prisma):**\n```prisma\nmodel Session {\n  id        String    @id\n  shop      String\n  state     String\n  isOnline  Boolean   @default(false)\n  accessToken String\n  refreshToken String?\n  scope     String\n  expiresAt DateTime?\n  createdAt DateTime  @default(now())\n  updatedAt DateTime  @updatedAt\n\n  @@unique([shop, state])\n  @@index([shop])\n}\n```\n\n**Setup (shopify.app.ts):**\n```typescript\nimport { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';\nimport { prisma } from '~/db.server';\n\nconst shopify = shopifyApp({\n  // ...\n  sessionStorage: new PrismaSessionStorage(prisma),\n});\n```\n\n### Redis\n\n```typescript\nimport { RedisSessionStorage } from '@shopify/shopify-app-session-storage-redis';\nimport redis from 'redis';\n\nconst redisClient = redis.createClient({\n  host: 'localhost',\n  port: 6379,\n});\n\nconst sessionStorage = new RedisSessionStorage({\n  client: redisClient,\n  prefix: 'shopify_session:',\n});\n\nconst shopify = shopifyApp({\n  // ...\n  sessionStorage,\n});\n```\n\n### In-Memory (Development Only)\n\n```typescript\nimport { MemorySessionStorage } from '@shopify/shopify-app-session-storage';\n\nconst sessionStorage = new MemorySessionStorage();\n\nconst shopify = shopifyApp({\n  // ...\n  sessionStorage, // CAUTION: Sessions lost on app restart; development only\n});\n```\n\n### DynamoDB\n\n```typescript\nimport { DynamoDBSessionStorage } from '@shopify/shopify-app-session-storage-dynamodb';\nimport { DynamoDBClient } from '@aws-sdk/client-dynamodb';\n\nconst dynamoDBClient = new DynamoDBClient({ region: 'us-east-1' });\n\nconst sessionStorage = new DynamoDBSessionStorage({\n  client: dynamoDBClient,\n  tableName: 'shopify-sessions',\n});\n\nconst shopify = shopifyApp({\n  // ...\n  sessionStorage,\n});\n```\n\n## Online vs. Offline Tokens\n\n**Online Token:**\n- Scope: Current user's permissions (typically admin user)\n- Expiration: 24 hours\n- Use Case: Browser-based actions (Polaris admin dashboard)\n- Limitations: Cannot run background jobs; limited when user logs out\n\n**Offline Token:**\n- Scope: App's granted scopes\n- Expiration: None; permanent until revoked\n- Use Case: Background jobs, webhooks, scheduled tasks\n- Limitations: None; use by default for app operations\n\n**Requesting Offline Token in Token Exchange:**\n```typescript\n// shopify.app.ts\nconst shopify = shopifyApp({\n  // ...\n  auth: {\n    path: '/auth',\n    callbackPath: '/auth/callback',\n  },\n});\n\n// Remix automatically requests offline token by default\n// No action needed; use session.accessToken for API calls\n```\n\n**Using Offline Token for Webhooks:**\n```typescript\n// webhooks/products-update.ts\nexport const webhooks = {\n  APP_UNINSTALLED: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/app-uninstalled',\n  },\n  PRODUCTS_UPDATE: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/products-update',\n  },\n};\n\nexport default defineWebhooksConfig(\n  async (request, { admin, session }) => {\n    const { body, query } = await graphql.query(request, {\n      query: GET_PRODUCT,\n      variables: { id: 'gid://shopify/Product/123' },\n    });\n\n    // session.accessToken is offline token; valid here\n    console.log(`Webhook processed with token for shop: ${session.shop}`);\n  },\n  webhooksConfig\n);\n```\n\n## App Proxy Authentication\n\n**Use Case:** Storefront (public-facing) requests to app backend\n**Authentication:** HMAC signature validation (like webhooks)\n**Flow:** Storefront → Liquid proxy request → App backend (validates HMAC) → Response\n\n**Setting Up App Proxy (shopify.app.toml):**\n```toml\n[[extensions]]\ntype = \"app_proxy\"\nname = \"Storefront API\"\nurl = \"/api/proxy\"\nsubpath = \"loyalty\"  # Requests to /apps/loyalty/* are routed here\n```\n\n**App Proxy Handler (Remix routes/api/proxy.ts):**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport crypto from 'crypto';\n\nexport const loader = async ({ request }) => {\n  const url = new URL(request.url);\n  const hmac = url.searchParams.get('hmac') || '';\n  const timestamp = url.searchParams.get('_t') || '';\n  const shop = url.searchParams.get('shop') || '';\n\n  // Build message to validate HMAC\n  const params = new URLSearchParams();\n  Array.from(url.searchParams.entries()).forEach(([key, value]) => {\n    if (key !== 'hmac') params.append(key, value);\n  });\n\n  const message = params.toString();\n  const hash = crypto\n    .createHmac('sha256', process.env.SHOPIFY_API_SECRET || '')\n    .update(message, 'utf8')\n    .digest('base64');\n\n  if (hash !== hmac) {\n    return json({ error: 'Unauthorized' }, { status: 401 });\n  }\n\n  // Validate timestamp (within 24 hours)\n  const requestTime = parseInt(timestamp, 10);\n  const currentTime = Math.floor(Date.now() / 1000);\n  if (Math.abs(currentTime - requestTime) > 86400) {\n    return json({ error: 'Request expired' }, { status: 401 });\n  }\n\n  // Valid app proxy request; return customer's loyalty points\n  const customerId = url.searchParams.get('customer_id') || '';\n  const points = await getLoyaltyPoints(customerId);\n\n  return json({ points });\n};\n\nasync function getLoyaltyPoints(customerId: string) {\n  // Fetch from database\n  return 1500; // Example\n}\n```\n\n**Storefront Liquid Snippet:**\n```liquid\n<div id=\"loyalty-widget\">\n  <p>Your loyalty points: <span id=\"points\">Loading...</span></p>\n</div>\n\n<script>\nfetch('/apps/loyalty?customer_id={{ customer.id }}')\n  .then(r => r.json())\n  .then(data => {\n    document.getElementById('points').textContent = data.points;\n  });\n</script>\n```\n\n## Webhook Signature Verification\n\n**How It Works:** Shopify sends HMAC-SHA256 signature in `X-Shopify-Hmac-SHA256` header\n\n**Verify Signature (Manual):**\n```typescript\nimport crypto from 'crypto';\n\nexport const verifyWebhookSignature = (\n  request: Request,\n  secret: string\n): boolean => {\n  const hmacHeader = request.headers.get('X-Shopify-Hmac-SHA256') || '';\n  const body = request.body; // Must be raw bytes, not JSON\n\n  const hash = crypto\n    .createHmac('sha256', secret)\n    .update(body)\n    .digest('base64');\n\n  return crypto.timingSafeEqual(\n    Buffer.from(hash),\n    Buffer.from(hmacHeader)\n  );\n};\n```\n\n**Verify Signature (Remix Shopify Package):**\n```typescript\nimport { authenticate } from '~/shopify.server';\n\nexport const action = async ({ request }) => {\n  const { webhook } = await authenticate.webhook(request);\n\n  // Signature already validated by middleware\n  console.log(`Webhook received for shop: ${webhook.shop}`);\n  console.log(`Topic: ${webhook.topic}`);\n  console.log(`Body:`, webhook.payload);\n\n  return json({ status: 'ok' });\n};\n```\n\n**Register Webhook (shopify.app.ts):**\n```typescript\nconst shopify = shopifyApp({\n  // ...\n  webhooks: {\n    path: '/webhooks',\n    validateHmac: true, // Automatic signature verification\n  },\n});\n\n// Define webhooks in routes/webhooks.ts\nexport const webhooks = {\n  APP_UNINSTALLED: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/app-uninstalled',\n  },\n  ORDERS_CREATE: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/orders-create',\n  },\n};\n```\n\n## Customer Account API Authentication\n\n**Use Case:** Access customer account data (orders, addresses, metafields)\n**Authentication:** Customer-specific access tokens (from Shopify Hydrogen or customer flow)\n**Note:** Different from admin API; limited to customer data only\n\n**Get Customer Access Token (in Hydrogen/Storefront):**\n```typescript\n// This is typically handled by Shopify's customer auth flow\nconst customerAccessToken = 'shpuc_XXXXX'; // Provided by auth\n\nconst response = await fetch(\n  `https://example-shop.myshopify.com/api/2026-07/graphql.json`,\n  {\n    method: 'POST',\n    headers: {\n      'X-Shopify-Storefront-Access-Token': 'public-storefront-token',\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({\n      query: `\n        query {\n          customer(customerAccessToken: \"${customerAccessToken}\") {\n            id\n            firstName\n            email\n            orders(first: 10) {\n              edges {\n                node {\n                  id\n                  orderNumber\n                  totalPrice\n                }\n              }\n            }\n          }\n        }\n      `,\n    }),\n  }\n);\n```\n\n## Public vs. Custom vs. Custom Distribution Apps\n\n**Public App (Shopify App Store):**\n- Listed in Shopify App Store\n- OAuth 2.0 or Token Exchange\n- Scopes reviewed by Shopify\n- Available to all merchants\n- Example: \"Email Marketing Pro\"\n\n**Custom App (Internal Use):**\n- Private; not listed in App Store\n- No scopes; request all access\n- Created by/for single merchant\n- Only accessible to merchant account\n- Example: Custom inventory sync for specific store\n\n**Custom Distribution App:**\n- Limited distribution; only shared with specific merchants via link\n- Behaves like public app (listed in custom store) but not publicly visible\n- OAuth 2.0 required\n- Scopes still reviewed\n\n**Configuration (shopify.app.toml):**\n```toml\n# Public App\nscopes = \"write_products,read_products,write_orders\"\ndistribution = \"public\"\n\n# Custom App (all scopes by default)\ndistribution = \"private\"\n\n# Custom Distribution\ndistribution = \"custom\"\nallowedDomains = [\"company-partner.myshopify.com\"]\n```\n\n## Top 10 Authentication Bugs & Fixes\n\n| Bug | Symptom | Fix |\n|-----|---------|-----|\n| **Missing HMAC validation** | Webhook spoofing; malicious requests processed | Always validate HMAC signature in webhook handlers; use `authenticate.webhook(request)` |\n| **Storing access token in session cookie** | Token exposed in browser; XSS vulnerability | Store token in database (Prisma); never send to client; use httpOnly cookies for session ID only |\n| **Expired token not refreshed** | \"Unauthorized\" errors after 24h (online tokens) | Token Exchange auto-refreshes; OAuth tokens are permanent; Managed Installation tokens never expire |\n| **Incorrect HMAC secret** | \"Invalid signature\" errors on valid requests | Use correct `SHOPIFY_API_SECRET`; verify in Partner Dashboard > App Settings |\n| **State parameter not validated** | CSRF attacks; attacker redirects merchant | Store state in session; validate in callback; use `crypto.randomBytes(16).toString('hex')` for state generation |\n| **App proxy timestamp not checked** | Old requests replayed; business logic executed twice | Validate timestamp within 24h; use `Math.abs(currentTime - timestamp) < 86400` |\n| **Scope creep (requesting too many scopes)** | App rejected by Shopify; merchant distrust | Request only scopes needed; remove unused scopes from SCOPES array |\n| **Token stored in environment variable for multi-tenant** | Security breach; one merchant's token accessed by another | Use database storage (Prisma/Redis); one token per shop; index by shop domain |\n| **Webhook signature verified but not in constant-time** | Timing attacks; signature can be guessed | Use `crypto.timingSafeEqual()` for comparison; avoid simple `===` |\n| **Customer access token hardcoded** | Exposed in source code; customer data accessed | Never hardcode tokens; pass via environment variables or customer auth flow |\n\n## Full Working Examples\n\n### Example 1: Remix Token Exchange App (Complete)\n\n**Directory Structure:**\n```\nmy-app/\n├── app/\n│   ├── routes/\n│   │   ├── auth.$.tsx (Auth handler)\n│   │   ├── auth.callback.tsx (Callback)\n│   │   └── app.products.tsx (Protected route)\n│   ├── shopify.server.ts (Config)\n│   └── db.server.ts (Prisma client)\n├── prisma/\n│   ├── schema.prisma\n│   └── migrations/\n├── .env (API keys)\n└── shopify.app.toml\n```\n\n**shopify.app.toml:**\n```toml\nscopes = \"write_products,read_products,write_orders,read_orders\"\n```\n\n**app/shopify.server.ts:**\n```typescript\nimport { shopifyApp } from '@shopify/shopify-app-remix/server';\nimport { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';\nimport { prisma } from '~/db.server';\n\nexport const shopify = shopifyApp({\n  apiKey: process.env.SHOPIFY_API_KEY,\n  apiSecret: process.env.SHOPIFY_API_SECRET,\n  scopes: process.env.SCOPES?.split(',') || [],\n  appUrl: process.env.SHOPIFY_APP_URL,\n  auth: {\n    path: '/auth',\n    callbackPath: '/auth/callback',\n  },\n  webhooks: {\n    path: '/webhooks',\n  },\n  isEmbeddedApp: true,\n  sessionStorage: new PrismaSessionStorage(prisma),\n});\n\nexport const authenticate = shopify.authenticate;\n```\n\n**app/routes/auth.$.tsx:**\n```typescript\nimport { redirect } from '@shopify/remix-oxygen';\nimport { authenticate } from '~/shopify.server';\n\nexport const loader = async ({ request }) => {\n  await authenticate.admin(request);\n  return redirect('/app');\n};\n```\n\n**app/routes/auth.callback.tsx:**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport { authenticate } from '~/shopify.server';\n\nexport const loader = async ({ request }) => {\n  const { session } = await authenticate.admin(request);\n  return redirect(`/app?shop=${session.shop}`);\n};\n```\n\n**app/routes/app.products.tsx:**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport { useLoaderData } from '@remix-run/react';\nimport { authenticate } from '~/shopify.server';\n\nexport const loader = async ({ request }) => {\n  const { admin } = await authenticate.admin(request);\n\n  const response = await admin.graphql(`\n    query GetProducts {\n      products(first: 10) {\n        edges {\n          node {\n            id\n            title\n          }\n        }\n      }\n    }\n  `);\n\n  const products = response.data?.products?.edges || [];\n\n  return json({ products });\n};\n\nexport default function Products() {\n  const { products } = useLoaderData<typeof loader>();\n\n  return (\n    <div>\n      <h1>Products</h1>\n      <ul>\n        {products.map((p) => (\n          <li key={p.node.id}>{p.node.title}</li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n```\n\n### Example 2: Webhook Signature Verification\n\n**routes/webhooks.ts:**\n```typescript\nimport { define } from '@shopify/shopify-app-remix/server';\nimport { DeliveryMethod } from '@shopify/shopify-api';\n\nexport const webhooks = define({\n  APP_UNINSTALLED: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/app-uninstalled',\n  },\n  PRODUCTS_CREATE: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/products-create',\n  },\n  PRODUCTS_UPDATE: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/products-update',\n  },\n});\n\nexport async function handleWebhook(\n  topic,\n  shop,\n  body,\n  webhookId\n) {\n  switch (topic) {\n    case 'app/uninstalled':\n      await deleteShopData(shop);\n      break;\n    case 'products/create':\n      await syncProduct(shop, body);\n      break;\n    case 'products/update':\n      await updateProduct(shop, body);\n      break;\n  }\n}\n\nasync function deleteShopData(shop: string) {\n  // Clean up shop data on uninstall\n  console.log(`App uninstalled for shop: ${shop}`);\n}\n\nasync function syncProduct(shop: string, body: any) {\n  const product = JSON.parse(body).product;\n  console.log(`Product created in ${shop}: ${product.title}`);\n}\n\nasync function updateProduct(shop: string, body: any) {\n  const product = JSON.parse(body).product;\n  console.log(`Product updated in ${shop}: ${product.title}`);\n}\n```\n\n**routes/webhooks/app-uninstalled.tsx:**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport { authenticate } from '~/shopify.server';\nimport { deleteShopData } from '~/models/shop.server';\n\nexport const action = async ({ request }) => {\n  const { webhook } = await authenticate.webhook(request);\n\n  // HMAC signature already validated\n  await deleteShopData(webhook.shop);\n\n  return json({ status: 'processed' });\n};\n```\n\n### Example 3: App Proxy with Customer Loyalty\n\n**routes/api/proxy.tsx:**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport crypto from 'crypto';\n\nexport const loader = async ({ request }) => {\n  const url = new URL(request.url);\n  const hmac = url.searchParams.get('hmac') || '';\n  const timestamp = url.searchParams.get('_t') || '';\n\n  // Build message for HMAC validation\n  const params = new URLSearchParams();\n  Array.from(url.searchParams.entries()).forEach(([key, value]) => {\n    if (key !== 'hmac') params.append(key, value);\n  });\n\n  const message = params.toString();\n  const hash = crypto\n    .createHmac('sha256', process.env.SHOPIFY_API_SECRET || '')\n    .update(message, 'utf8')\n    .digest('base64');\n\n  // Validate signature\n  if (hash !== hmac) {\n    return json({ error: 'Unauthorized' }, { status: 401 });\n  }\n\n  // Validate timestamp\n  const requestTime = parseInt(timestamp, 10);\n  const currentTime = Math.floor(Date.now() / 1000);\n  if (Math.abs(currentTime - requestTime) > 86400) {\n    return json({ error: 'Request expired' }, { status: 401 });\n  }\n\n  // Valid request; return loyalty data\n  const customerId = url.searchParams.get('customer_id') || '';\n  const loyaltyPoints = await getLoyaltyPoints(customerId);\n\n  return json({ loyaltyPoints, success: true });\n};\n\nasync function getLoyaltyPoints(customerId: string): Promise<number> {\n  // Fetch from database\n  return 1500;\n}\n```\n\n## API Version & Scope Reference\n\n**Current when this release was audited:** `2026-07`. Verify Shopify's latest stable version before deployment.\n\n**Essential Scopes:**\n- `write_products`, `read_products` — Manage product catalog\n- `write_orders`, `read_orders` — Access order data\n- `write_customers`, `read_customers` — Manage customer data\n- `write_fulfillments`, `read_fulfillments` — Manage fulfillments\n- `write_inventory`, `read_inventory` — Manage inventory levels\n- `write_discounts`, `read_discounts` — Create/manage discounts\n- `write_draft_orders`, `read_draft_orders` — Draft order management\n- `write_checkout`, `read_checkout` — Checkout customization\n- `write_metafields`, `read_metafields` — Manage custom data\n\n**Scope Review:** Scopes are reviewed by Shopify during app approval. Request only necessary scopes.\n\n## Resources\n\n- **Shopify OAuth Docs:** https://shopify.dev/docs/apps/auth\n- **Session Storage:** https://shopify.dev/docs/apps/auth-session-storage\n- **Webhook Verification:** https://shopify.dev/docs/apps/webhooks/configuration/verify-webhook-authenticity\n- **Token Exchange:** https://shopify.dev/docs/apps/auth/get-access-tokens/token-exchange\n"},"changes":[],"summary":"First saved snapshot. No earlier version is available for comparison.","summary_kind":"deterministic","summary_metadata":{}}