セキュリティベストプラクティス
セキュリティは Shopify アプリ開発において最も重要な側面です。マーチャントデータ、顧客の個人識別情報(PII)、決済情報、ビジネスクリティカルなオペレーションを扱っています。セキュリティ侵害はアプリだけに影響するのではなく、ストアを預けているすべてのマーチャントに影響します。Shopify はアプリレビュー中に厳格なセキュリティ要件を強制し、違反は App Store からの即時削除につながる可能性があります。
OAuth 2.0 の実装
Shopify はアプリ認可に OAuth 2.0 を使用します。フローを深く理解することは、正しく実装するために不可欠です。
認可フロー
Merchant clicks "Install" → Shopify redirects to your /auth endpoint
→ You validate the request → Redirect merchant to Shopify's permission screen
→ Merchant approves → Shopify redirects back with authorization code
→ You exchange code for access token → Store token securely
安全な OAuth 実装
// routes/auth.js
import crypto from 'crypto';
import { shopifyApi } from '@shopify/shopify-api';
const shopify = shopifyApi({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET,
scopes: ['read_products', 'write_products', 'read_orders'],
hostName: process.env.HOST.replace(/^https?:\/\//, ''),
});
// Step 1: Begin OAuth -- validate and redirect
export async function beginAuth(req, res) {
const shop = req.query.shop;
// CRITICAL: Validate the shop parameter
if (!shop || !/^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/.test(shop)) {
return res.status(400).send('Invalid shop parameter');
}
// Generate a cryptographically secure nonce
const nonce = crypto.randomBytes(16).toString('hex');
// Store nonce in session for CSRF protection
req.session.oauthNonce = nonce;
req.session.oauthShop = shop;
const authUrl = `https://${shop}/admin/oauth/authorize?` +
`client_id=${process.env.SHOPIFY_API_KEY}` +
`&scope=${shopify.config.scopes.toString()}` +
`&redirect_uri=${process.env.HOST}/auth/callback` +
`&state=${nonce}`;
res.redirect(authUrl);
}
// Step 2: Handle callback -- validate and exchange token
export async function authCallback(req, res) {
const { shop, code, state, hmac, timestamp } = req.query;
// Validate state matches our nonce (CSRF protection)
if (state !== req.session.oauthNonce) {
return res.status(403).send('State mismatch -- possible CSRF attack');
}
// Validate HMAC signature
if (!verifyHmac(req.query, process.env.SHOPIFY_API_SECRET)) {
return res.status(403).send('HMAC validation failed');
}
// Validate shop matches the original request
if (shop !== req.session.oauthShop) {
return res.status(403).send('Shop mismatch');
}
// Validate timestamp is recent (within 5 minutes)
const currentTime = Math.floor(Date.now() / 1000);
if (currentTime - parseInt(timestamp) > 300) {
return res.status(403).send('Request expired');
}
// Exchange authorization code for access token
const tokenResponse = await fetch(
`https://${shop}/admin/oauth/access_token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.SHOPIFY_API_KEY,
client_secret: process.env.SHOPIFY_API_SECRET,
code,
}),
}
);
const { access_token } = await tokenResponse.json();
// Store token securely (encrypted at rest)
await storeAccessToken(shop, access_token);
// Clean up session
delete req.session.oauthNonce;
delete req.session.oauthShop;
res.redirect(`/app?shop=${shop}`);
}
Never log access tokens, include them in URLs, or store them in plain text. Access tokens grant full access to a merchant's store within your app's scopes. Treat them like passwords -- encrypt at rest and transmit only over HTTPS.
Webhook の HMAC バリデーション
Every webhook from Shopify includes an X-Shopify-Hmac-Sha256 header. You must validate this header to ensure the webhook is genuinely from Shopify.
// middleware/webhook-verification.js
import crypto from 'crypto';
export function verifyWebhookHmac(req, res, next) {
const hmacHeader = req.get('X-Shopify-Hmac-Sha256');
if (!hmacHeader) {
console.error('Missing HMAC header on webhook request');
return res.status(401).send('Unauthorized');
}
// IMPORTANT: Use the raw body, not the parsed JSON body
const rawBody = req.rawBody;
if (!rawBody) {
console.error('Raw body not available -- ensure body parser preserves it');
return res.status(500).send('Server configuration error');
}
const generatedHmac = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET)
.update(rawBody, 'utf8')
.digest('base64');
// Use timing-safe comparison to prevent timing attacks
const isValid = crypto.timingSafeEqual(
Buffer.from(hmacHeader),
Buffer.from(generatedHmac)
);
if (!isValid) {
console.error('HMAC validation failed for webhook');
return res.status(401).send('Unauthorized');
}
next();
}
// Express setup to preserve raw body
import express from 'express';
const app = express();
app.use('/webhooks', express.json({
verify: (req, _res, buf) => {
req.rawBody = buf.toString('utf8');
},
}));
app.post('/webhooks/:topic', verifyWebhookHmac, handleWebhook);
A common mistake is parsing the JSON body before HMAC validation. JSON parsing can alter whitespace, changing the signature. Always validate the HMAC against the raw request body before any parsing.
コンテンツセキュリティポリシー
Shopify apps embedded in the admin run inside an iframe. You must configure CSP headers to allow Shopify's embedding while blocking malicious content injection.
// middleware/security-headers.js
export function securityHeaders(req, res, next) {
const shop = req.query.shop || req.session?.shop;
// Allow embedding by Shopify admin only
res.setHeader(
'Content-Security-Policy',
[
`frame-ancestors https://${shop} https://admin.shopify.com`,
"default-src 'self'",
"script-src 'self' https://cdn.shopify.com",
"style-src 'self' 'unsafe-inline' https://cdn.shopify.com",
"img-src 'self' https://cdn.shopify.com data:",
"connect-src 'self' https://*.myshopify.com https://*.shopify.com",
"font-src 'self' https://cdn.shopify.com",
].join('; ')
);
// Additional security headers
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY'); // Overridden by CSP for Shopify
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
}
GDPR コンプライアンス -- 必須 Webhook
Shopify requires all apps to handle three mandatory GDPR webhooks. Failing to implement these will result in app rejection.
3つの必須 Webhook
// routes/gdpr.js
/**
* 1. Customer Data Request
* Shopify sends this when a customer requests their data.
* You must respond with all data you store about this customer.
*/
export async function handleCustomerDataRequest(req, res) {
const { shop_domain, customer } = req.body;
// Queue a job to gather all customer data
await queue.add('gdpr:customer-data-request', {
shopDomain: shop_domain,
customerId: customer.id,
email: customer.email,
});
// Respond immediately -- process asynchronously
res.status(200).send({ received: true });
}
/**
* 2. Customer Data Erasure
* Shopify sends this when a customer requests deletion of their data.
* You must delete all data you store about this customer.
*/
export async function handleCustomerRedact(req, res) {
const { shop_domain, customer, orders_to_redact } = req.body;
await queue.add('gdpr:customer-redact', {
shopDomain: shop_domain,
customerId: customer.id,
email: customer.email,
orderIds: orders_to_redact,
});
res.status(200).send({ received: true });
}
/**
* 3. Shop Data Erasure
* Shopify sends this 48 hours after a merchant uninstalls your app.
* You must delete ALL data related to this shop.
*/
export async function handleShopRedact(req, res) {
const { shop_domain, shop_id } = req.body;
await queue.add('gdpr:shop-redact', {
shopDomain: shop_domain,
shopId: shop_id,
});
res.status(200).send({ received: true });
}
GDPR webhooks must be processed within 30 days of receipt. Implement a job queue with retry logic and monitoring to ensure you never miss a compliance deadline. Log the completion of each GDPR request for audit purposes.
GDPR ジョブプロセッサー
// workers/gdpr-processor.js
export async function processShopRedact({ shopDomain, shopId }) {
const db = getDatabase();
try {
// Delete in dependency order to respect foreign keys
await db.transaction(async (tx) => {
await tx.delete('webhook_logs').where({ shopId });
await tx.delete('sync_records').where({ shopId });
await tx.delete('app_settings').where({ shopId });
await tx.delete('sessions').where({ shopId });
await tx.delete('shops').where({ id: shopId });
});
// Also delete from external services
await analyticsService.deleteShopData(shopDomain);
await cacheService.invalidateShop(shopDomain);
// Log completion for audit trail
await auditLog.record({
type: 'SHOP_REDACT_COMPLETED',
shopDomain,
completedAt: new Date().toISOString(),
});
} catch (error) {
// Re-throw to trigger retry
console.error(`Shop redact failed for ${shopDomain}:`, error);
throw error;
}
}
アクセストークンローテーション(2026年1月以降)
Since January 2026, Shopify supports access token rotation for improved security. When enabled, access tokens have a configurable expiration period, and your app must handle token refresh automatically. This is strongly recommended for apps handling sensitive data and will become mandatory for new apps in a future release.
// services/token-manager.js
import { encrypt, decrypt } from './encryption.js';
export class TokenManager {
constructor(db) {
this.db = db;
}
async getValidToken(shopDomain) {
const record = await this.db.findOne('tokens', { shopDomain });
if (!record) {
throw new Error(`No token found for ${shopDomain}`);
}
const token = decrypt(record.encryptedToken);
const expiresAt = new Date(record.expiresAt);
// Refresh if token expires within 5 minutes
if (expiresAt - Date.now() < 5 * 60 * 1000) {
return await this.rotateToken(shopDomain, token);
}
return token;
}
async rotateToken(shopDomain, currentToken) {
const response = await fetch(
`https://${shopDomain}/admin/oauth/access_token/rotate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': currentToken,
},
}
);
if (!response.ok) {
throw new Error(`Token rotation failed: ${response.statusText}`);
}
const { access_token, expires_at } = await response.json();
// Store the new token encrypted
await this.db.update('tokens', {
where: { shopDomain },
data: {
encryptedToken: encrypt(access_token),
expiresAt: expires_at,
rotatedAt: new Date().toISOString(),
},
});
return access_token;
}
}
アプリレビューセキュリティチェックリスト
Before submitting your app, verify every item on this list:
| 要件 | ステータス | メモ |
|---|---|---|
| OAuth HMAC validation | Required | Verify all incoming requests from Shopify |
| Webhook HMAC validation | Required | Timing-safe comparison on raw body |
| GDPR webhooks implemented | Required | All three endpoints functional |
| CSP headers configured | Required | frame-ancestors must include Shopify admin |
| Access tokens encrypted at rest | Required | Use AES-256 or equivalent |
| No secrets in client-side code | Required | No API keys in frontend bundles |
| HTTPS everywhere | Required | All endpoints must use TLS 1.2+ |
| Input validation | Required | Sanitize all user inputs |
| SQL injection prevention | Required | Use parameterized queries exclusively |
| Rate limiting on public endpoints | Recommended | Prevent abuse of your API |
| Access token rotation | Recommended | Will be mandatory in a future release |
| Dependency vulnerability scanning | Recommended | Run npm audit in CI |
Run shopify app dev with the --verbose flag during development to see exactly what headers your app sends and receives. This helps catch CSP and authentication issues before they reach the app review team.
依存関係セキュリティスキャン
Add automated vulnerability scanning to your CI pipeline:
# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm audit --audit-level=high
- name: Run Snyk security scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
セキュリティは最後に追加する機能ではありません。最初の一行のコードからアプリケーションのすべてのレイヤーに織り込まれなければなりません。このモジュールのパターンは最低限のベースラインです -- Shopify Changelog とパートナーブログをモニタリングして、Shopify の進化するセキュリティ要件に常に最新の状態を保ちましょう。