| name | security-hardening |
| description | Security best practices for Shopify Apps. Covers OWASP Top 10, authentication, data protection, webhook verification, and secure coding patterns for Remix applications. |
Security Hardening for Shopify Apps
This skill covers essential security practices for building secure Shopify apps. Security is non-negotiable when handling merchant data, customer PII, and payment information.
Why Security Matters for Shopify Apps
- Merchant Trust: Apps handle sensitive business data
- Customer PII: Access to customer names, emails, addresses
- Payment Data: Some apps process or display financial information
- App Store Requirements: Shopify reviews apps for security compliance
- Legal Liability: GDPR, CCPA, and data protection regulations apply
1. Authentication & Authorization
Session Management
import { authenticate } from '~/shopify.server';
export async function loader({ request }: LoaderFunctionArgs) {
const { admin, session } = await authenticate.admin(request);
return json({
shop: session.shop,
});
}
Route Protection Pattern
import { authenticate } from '~/shopify.server';
import { redirect } from '@remix-run/node';
export async function requireAuth(request: Request) {
try {
return await authenticate.admin(request);
} catch (error) {
console.error('Authentication failed:', error);
throw redirect('/auth/login');
}
}
export async function loader({ request }: LoaderFunctionArgs) {
const { admin, session } = await requireAuth(request);
}
API Route Protection
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { authenticate } from '~/shopify.server';
export async function action({ request }: ActionFunctionArgs) {
const { admin, session } = await authenticate.admin(request);
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
}
export async function loader({ request }: LoaderFunctionArgs) {
await authenticate.admin(request);
return json({ error: 'Use POST' }, { status: 405 });
}
2. Webhook Security
HMAC Verification (Critical)
import crypto from 'crypto';
import { json, type ActionFunctionArgs } from '@remix-run/node';
export async function action({ request }: ActionFunctionArgs) {
const { topic, session, admin, payload } = await authenticate.webhook(request)
return json({ success: true });
}
2. Input Validation & Sanitization
Never Trust User Input
import { z } from 'zod';
import DOMPurify from 'isomorphic-dompurify';
export function sanitizeHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
});
}
export const shopifyGidSchema = z
.string()
.regex(
/^gid:\/\/shopify\/[A-Za-z]+\/\d+$/,
'Invalid Shopify GID format'
);
export const productInputSchema = z.object({
title: z
.string()
.min(1)
.max(255)
.transform(val => val.trim()),
description: z
.string()
.max(5000)
.transform(sanitizeHtml)
.optional(),
vendor: z
.string()
.max(255)
.transform(val => val.trim())
.optional(),
tags: z
.array(z.string().max(50).transform(val => val.trim()))
.max(100)
.optional(),
});
SQL Injection Prevention
export async function findProducts(shop: string, search: string) {
return db.product.findMany({
where: {
shop,
title: {
contains: search,
mode: 'insensitive',
},
},
});
}
export async function rawSearch(shop: string, search: string) {
return db.$queryRaw`
SELECT * FROM products
WHERE shop = ${shop}
AND title ILIKE ${`%${search}%`}
`;
}
Command Injection Prevention
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
export async function generatePdf(templatePath: string, outputPath: string) {
if (!templatePath.startsWith('/app/templates/')) {
throw new Error('Invalid template path');
}
await execFileAsync('wkhtmltopdf', [
'--quiet',
templatePath,
outputPath,
]);
}
4. XSS Prevention
React/Remix Built-in Protection
function ProductTitle({ title }: { title: string }) {
return <h1>{title}</h1>;
}
function ProductDescription({ html }: { html: string }) {
const sanitized = DOMPurify.sanitize(html);
return (
<div
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
}
Content Security Policy
import { renderToString } from 'react-dom/server';
import { RemixServer } from '@remix-run/react';
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const markup = renderToString(
<RemixServer context={remixContext} url={request.url} />
);
responseHeaders.set('Content-Type', 'text/html');
responseHeaders.set(
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self' https://cdn.shopify.com",
"style-src 'self' 'unsafe-inline' https://cdn.shopify.com",
"img-src 'self' data: https://cdn.shopify.com https://*.shopifycdn.com",
"connect-src 'self' https://*.shopify.com",
"frame-ancestors https://*.myshopify.com https://admin.shopify.com",
].join('; ')
);
return new Response('<!DOCTYPE html>' + markup, {
status: responseStatusCode,
headers: responseHeaders,
});
}
5. Data Protection
Environment Variables Security
SHOPIFY_API_KEY=your_api_key
SHOPIFY_API_SECRET=your_api_secret
DATABASE_URL=postgresql:
ENCRYPTION_KEY=your_32_byte_encryption_key
import { z } from 'zod';
const envSchema = z.object({
SHOPIFY_API_KEY: z.string().min(1),
SHOPIFY_API_SECRET: z.string().min(1),
DATABASE_URL: z.string().url(),
ENCRYPTION_KEY: z.string().length(32),
NODE_ENV: z.enum(['development', 'production', 'test']),
});
export const env = envSchema.parse(process.env);
console.log('Starting app with API key:', env.SHOPIFY_API_KEY.slice(0, 4) + '...');
Encrypting Sensitive Data
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const ENCRYPTION_KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');
export function encrypt(plaintext: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
export function decrypt(ciphertext: string): string {
const [ivHex, authTagHex, encrypted] = ciphertext.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
export async function storeSecureMetafield(
admin: AdminApiClient,
ownerId: string,
key: string,
value: string
) {
const encryptedValue = encrypt(value);
return admin.graphql(`
mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id }
userErrors { field message }
}
}
`, {
variables: {
metafields: [{
ownerId,
namespace: 'app_secure',
key,
value: encryptedValue,
type: 'single_line_text_field',
}],
},
});
}
Data Retention & Deletion
import { db } from '~/db.server';
import { encrypt, decrypt } from '~/lib/encryption.server';
export async function handleDataRequest(shop: string, customerId: string) {
const customerData = await db.customerData.findMany({
where: { shop, customerId },
});
return customerData.map(record => ({
...record,
email: record.encryptedEmail ? decrypt(record.encryptedEmail) : null,
phone: record.encryptedPhone ? decrypt(record.encryptedPhone) : null,
}));
}
export async function handleCustomerRedact(shop: string, customerId: string) {
await db.customerData.deleteMany({
where: { shop, customerId },
});
await db.auditLog.create({
data: {
shop,
action: 'customer_redact',
targetId: customerId,
timestamp: new Date(),
},
});
}
export async function handleShopRedact(shop: string) {
await db.$transaction([
db.product.deleteMany({ where: { shop } }),
db.order.deleteMany({ where: { shop } }),
db.customer.deleteMany({ where: { shop } }),
db.session.deleteMany({ where: { shop } }),
db.settings.deleteMany({ where: { shop } }),
]);
console.log(`Shop data redacted: ${shop}`);
}
6. Rate Limiting & DoS Prevention
Request Rate Limiting
import { LRUCache } from 'lru-cache';
interface RateLimitEntry {
count: number;
resetAt: number;
}
const cache = new LRUCache<string, RateLimitEntry>({
max: 10000,
ttl: 60000,
});
interface RateLimitOptions {
maxRequests: number;
windowMs: number;
}
export function rateLimit(
identifier: string,
options: RateLimitOptions = { maxRequests: 100, windowMs: 60000 }
): { allowed: boolean; remaining: number; resetAt: number } {
const now = Date.now();
const entry = cache.get(identifier);
if (!entry || now > entry.resetAt) {
const newEntry = {
count: 1,
resetAt: now + options.windowMs,
};
cache.set(identifier, newEntry);
return {
allowed: true,
remaining: options.maxRequests - 1,
resetAt: newEntry.resetAt,
};
}
if (entry.count >= options.maxRequests) {
return {
allowed: false,
remaining: 0,
resetAt: entry.resetAt,
};
}
entry.count++;
cache.set(identifier, entry);
return {
allowed: true,
remaining: options.maxRequests - entry.count,
resetAt: entry.resetAt,
};
}
export async function action({ request }: ActionFunctionArgs) {
const { session } = await authenticate.admin(request);
const { allowed, remaining, resetAt } = rateLimit(session.shop, {
maxRequests: 50,
windowMs: 60000,
});
if (!allowed) {
return json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)),
'X-RateLimit-Remaining': '0',
},
}
);
}
}
7. Secure Headers
export const securityHeaders = {
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
...(process.env.NODE_ENV === 'production' && {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
}),
};
Object.entries(securityHeaders).forEach(([key, value]) => {
responseHeaders.set(key, value);
});
8. Logging & Monitoring
Secure Logging Practices
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
redact: {
paths: [
'accessToken',
'password',
'secret',
'apiKey',
'authorization',
'*.accessToken',
'*.password',
'headers.authorization',
'headers.x-shopify-access-token',
],
censor: '[REDACTED]',
},
});
export { logger };
logger.info({ shop: session.shop, action: 'product_created' }, 'Product created');
Security Event Logging
import { logger } from './logger.server';
import { db } from '~/db.server';
export async function logSecurityEvent(
event: {
type: 'auth_failure' | 'rate_limit' | 'suspicious_activity' | 'data_access';
shop?: string;
ip?: string;
userAgent?: string;
details: Record<string, unknown>;
}
) {
logger.warn({ event }, `Security event: ${event.type}`);
await db.securityEvent.create({
data: {
type: event.type,
shop: event.shop,
ip: event.ip,
userAgent: event.userAgent,
details: JSON.stringify(event.details),
timestamp: new Date(),
},
});
if (event.type === 'suspicious_activity') {
}
}
9. Security Checklist
Before Deployment
Regular Audits
Shopify-Specific
Anti-Patterns to Avoid
- DON'T store access tokens in localStorage or cookies
- DON'T log full request/response bodies
- DON'T use
eval() or Function() constructor
- DON'T trust client-side validation alone
- DON'T hardcode secrets in source code
- DON'T disable HTTPS in production
- DON'T ignore security warnings from
npm audit
- DON'T use outdated dependencies with known vulnerabilities