Skip to main content

보안 모범 사례

보안은 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}`);
}
danger

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);
warning

흔한 실수는 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 });
}
info

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 실행
tip

개발 중에 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의 진화하는 보안 요구 사항을 항상 최신 상태로 유지하십시오.