Skip to main content

Security Best Practices

Security is the single most important aspect of Shopify app development. You are handling merchant data, customer PII, payment information, and business-critical operations. A security breach does not just affect your app -- it affects every merchant who trusts you with their store. Shopify enforces strict security requirements during app review, and violations can result in immediate removal from the App Store.

OAuth 2.0 Implementation

Shopify uses OAuth 2.0 for app authorization. Understanding the flow deeply is essential for implementing it correctly.

The Authorization Flow

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

Secure OAuth Implementation

// 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

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.

HMAC Validation for Webhooks

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

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.

Content Security Policy

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 Compliance -- Mandatory Webhooks

Shopify requires all apps to handle three mandatory GDPR webhooks. Failing to implement these will result in app rejection.

The Three Mandatory 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 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 Job Processor

// 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;
}
}

Access Token Rotation (Since January 2026)

Token Rotation

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;
}
}

App Review Security Checklist

Before submitting your app, verify every item on this list:

RequirementStatusNotes
OAuth HMAC validationRequiredVerify all incoming requests from Shopify
Webhook HMAC validationRequiredTiming-safe comparison on raw body
GDPR webhooks implementedRequiredAll three endpoints functional
CSP headers configuredRequiredframe-ancestors must include Shopify admin
Access tokens encrypted at restRequiredUse AES-256 or equivalent
No secrets in client-side codeRequiredNo API keys in frontend bundles
HTTPS everywhereRequiredAll endpoints must use TLS 1.2+
Input validationRequiredSanitize all user inputs
SQL injection preventionRequiredUse parameterized queries exclusively
Rate limiting on public endpointsRecommendedPrevent abuse of your API
Access token rotationRecommendedWill be mandatory in a future release
Dependency vulnerability scanningRecommendedRun npm audit in CI
tip

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.

Dependency Security Scanning

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 }}

Security is not a feature you add at the end. It must be woven into every layer of your application from the very first line of code. The patterns in this module represent the minimum baseline -- always stay current with Shopify's evolving security requirements by monitoring the Shopify Changelog and Partner Blog.