보안 모범 사례
보안은 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}`);
}
Access Token을 절대 로그에 기록하거나, URL에 포함하거나, 평문으로 저장하지 마십시오. Access Token은 앱 권한 범위 내에서 판매자 스토어에 대한 전체 접근을 부여합니다. 비밀번호처럼 취급하십시오 -- 저장 시 암호화하고 HTTPS를 통해서만 전송하십시오.
Webhooks를 위한 HMAC 유효성 검사
Shopify의 모든 웹훅에는 X-Shopify-Hmac-Sha256 헤더가 포함됩니다. 웹훅이 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);
흔한 실수는 HMAC 검증 전에 JSON 본문을 파싱하는 것입니다. JSON 파싱은 공백을 변경할 수 있어 서명이 달라집니다. 항상 파싱하기 전에 원시 요청 본문에 대해 HMAC을 검증하십시오.
Content Security Policy
Shopify Admin에 임베디드된 앱은 iframe 내부에서 실행됩니다. Shopify의 임베딩을 허용하면서 악성 콘텐츠 주입을 차단하도록 CSP 헤더를 구성해야 합니다.
// 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 컴플라이언스 -- 필수 Webhooks
Shopify는 모든 앱이 세 가지 필수 GDPR 웹훅을 처리하도록 요구합니다. 이를 구현하지 않으면 앱이 거부됩니다.
세 가지 필수 Webhooks
// 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 웹훅은 수신 후 30일 이내에 처리해야 합니다. 컴플라이언스 기한을 절대 놓치지 않도록 재시도 로직과 모니터링이 포함된 작업 큐를 구현하십시오. 감사 목적으로 각 GDPR 요청의 완료를 로깅하십시오.
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월부터)
2026년 1월부터 Shopify는 보안 강화를 위해 Access Token 로테이션을 지원합니다. 활성화하면 Access Token에 설정 가능한 만료 기간이 적용되며, 앱은 토큰 갱신을 자동으로 처리해야 합니다. 민감한 데이터를 처리하는 앱에 강력히 권장되며, 향후 릴리스에서 새로운 앱에는 필수가 될 예정입니다.
// 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;
}
}
앱 심사 보안 체크리스트
앱을 제출하기 전에 이 체크리스트의 모든 항목을 확인하십시오:
| 요구 사항 | 상태 | 비고 |
|---|---|---|
| OAuth HMAC 검증 | 필수 | Shopify로부터의 모든 수신 요청 검증 |
| Webhook HMAC 검증 | 필수 | 원시 본문에 대한 타이밍 안전 비교 |
| GDPR 웹훅 구현 | 필수 | 세 개 엔드포인트 모두 기능 |
| CSP 헤더 설정 | 필수 | frame-ancestors에 Shopify Admin 포함 필수 |
| Access Token 저장 시 암호화 | 필수 | AES-256 또는 동등 수준 사용 |
| 클라이언트 측 코드에 시크릿 없음 | 필수 | 프론트엔드 번들에 API 키 없음 |
| HTTPS 전면 적용 | 필수 | 모든 엔드포인트에 TLS 1.2+ 사용 필수 |
| 입력 유효성 검사 | 필수 | 모든 사용자 입력 살균 처리 |
| SQL 인젝션 방지 | 필수 | 매개변수화된 쿼리만 사용 |
| 공개 엔드포인트 Rate Limiting | 권장 | API 남용 방지 |
| Access Token 로테이션 | 권장 | 향후 릴리스에서 필수가 될 예정 |
| 종속성 취약점 스캐닝 | 권장 | CI에서 npm audit 실행 |
개발 중에 shopify app dev를 --verbose 플래그와 함께 실행하여 앱이 보내고 받는 헤더를 정확히 확인하십시오. 이를 통해 앱 심사 팀에 도달하기 전에 CSP 및 인증 문제를 발견할 수 있습니다.
종속성 보안 스캐닝
CI 파이프라인에 자동화된 취약점 스캐닝을 추가하십시오:
# .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와 Partner Blog를 모니터링하여 Shopify의 진화하는 보안 요구 사항을 항상 최신 상태로 유지하십시오.