MerchOps follows a defense-in-depth security strategy with emphasis on proactive threat mitigation, least privilege access, and comprehensive audit trails. This document outlines our security architecture, implementation patterns, and operational practices.
Security Principles:
- Calm over clever - Security controls should be transparent and understandable
- Control over automation - No security bypasses; explicit verification required
- Explainability over opacity - All security decisions are auditable
- Trust compounds faster than features - Security is not negotiable for velocity
MerchOps requests the minimum necessary scopes for each feature. Scopes are validated during OAuth installation and periodically audited.
read_products # Product catalog sync, inventory signals
read_inventory # Stock level monitoring, out-of-stock detection
read_orders # Order velocity analysis, customer activity
read_customers # Customer segmentation, dormancy detection
write_products # Pause/unpause products to prevent stockouts
write_price_rules # Create discount codes for inventory reduction
| Scope | Purpose | Features Enabled | Risk Level |
|---|---|---|---|
read_products |
Retrieve product catalog, monitor updates | Inventory signals, velocity tracking | Low |
read_inventory |
Monitor stock levels across locations | Out-of-stock detection, threshold alerts | Low |
read_orders |
Analyze order patterns and velocity | Spike detection, revenue impact | Low |
read_customers |
Segment by activity, identify dormancy | Win-back campaigns, re-engagement | Medium |
write_products |
Modify product status (active/paused) | Pause low-inventory products | High |
write_price_rules |
Create promotional discount codes | Inventory reduction discounts | High |
Implementation:
- Scopes are requested only when needed for approved actions
- Each write operation validates that the access token contains the required scope
- Scope expansion requires explicit user re-authorization via OAuth flow
- Workspace-level scope audit log tracks all scope grants and revocations
Token Storage:
- Access tokens stored encrypted at rest (see Secret Handling section)
- Token refresh implemented with automatic scope validation
- Revoked tokens immediately disable all write operations
- Token rotation enforced on security events
Scope Validation:
// Example: Validate scope before action execution
async function validateScope(accessToken: string, requiredScope: string): Promise<boolean> {
const tokenData = await decryptToken(accessToken);
if (!tokenData.scopes.includes(requiredScope)) {
logger.warn('Insufficient scope', { required: requiredScope, available: tokenData.scopes });
throw new InsufficientScopeError(requiredScope);
}
return true;
}Shopify signs all webhook requests with HMAC-SHA256. Every incoming webhook MUST be verified before processing to prevent:
- Spoofed webhook attacks
- Replay attacks
- Data injection via forged payloads
- Extract
X-Shopify-Hmac-Sha256header from incoming request - Compute HMAC-SHA256 of the raw request body using the shared webhook secret
- Compare computed HMAC with provided header using constant-time comparison
- Reject request if signatures do not match
MerchOps provides a dedicated webhook verification utility at /apps/web/server/shopify/verify-webhook.ts with the following features:
- HMAC-SHA256 signature verification with timing-safe comparison
- Replay attack prevention via timestamp validation
- Comprehensive webhook header extraction helpers
- Dedupe key generation for idempotent processing
Usage Example:
import { verifyWebhookSecure } from '@/server/shopify/verify-webhook';
export async function POST(request: Request) {
const rawBody = await request.text();
const result = verifyWebhookSecure(
rawBody,
request.headers,
process.env.SHOPIFY_API_SECRET
);
if (!result.verified) {
logger.warn('Webhook verification failed', {
error: result.error,
shop: result.shopDomain,
topic: result.topic,
});
return new Response('Unauthorized', { status: 401 });
}
// Process webhook safely
const payload = JSON.parse(rawBody);
await processWebhook(result.shopDomain, result.topic, payload);
return new Response('OK', { status: 200 });
}Strategy:
-
Timestamp Validation: Check
X-Shopify-Webhook-Timestampheader- Reject webhooks older than 5 minutes
- Prevents replay of captured requests
-
Idempotency Keys: Every webhook generates a dedupe key
const dedupeKey = `${workspaceId}:${topic}:${resourceId}:${timestamp}`;
- Stored in
eventstable to prevent duplicate processing - Enforced via unique constraint on
dedupe_key
- Stored in
-
Nonce Tracking (Optional): For high-security environments
- Store webhook IDs in short-lived cache (Redis, 10-minute TTL)
- Reject duplicate webhook IDs
Implementation:
async function processWebhook(req: Request): Promise<void> {
const hmacHeader = req.headers['x-shopify-hmac-sha256'];
const timestamp = req.headers['x-shopify-webhook-timestamp'];
const rawBody = await getRawBody(req);
// Step 1: Verify HMAC signature
if (!verifyShopifyWebhook(rawBody, hmacHeader, env.SHOPIFY_WEBHOOK_SECRET)) {
throw new UnauthorizedError('Invalid webhook signature');
}
// Step 2: Check timestamp freshness (prevent replay)
const webhookTime = parseInt(timestamp, 10) * 1000; // Convert to ms
const now = Date.now();
if (now - webhookTime > 5 * 60 * 1000) { // 5 minutes
throw new WebhookExpiredError('Webhook timestamp too old');
}
// Step 3: Dedupe via database constraint
const payload = JSON.parse(rawBody.toString());
const dedupeKey = generateDedupeKey(payload);
await createEvent({ dedupeKey, payload }); // Unique constraint on dedupe_key
}- Invalid Signature: Return 401 immediately, log security event
- Expired Timestamp: Return 400, log as potential replay attempt
- Duplicate Event: Return 200 (idempotent), do not reprocess
- Processing Error: Return 500, allow Shopify retry with exponential backoff
Core Principles:
- Secrets exist only in environment variables, never hardcoded
- Access tokens encrypted at rest using application-level encryption
- Secrets never logged, never sent to client, never stored in version control
- Regular key rotation enforced via automated policies
Configuration Pattern:
# Server-only secrets (NEVER prefixed with NEXT_PUBLIC_)
SHOPIFY_CLIENT_ID=<shopify-app-client-id>
SHOPIFY_CLIENT_SECRET=<shopify-app-secret>
SHOPIFY_WEBHOOK_SECRET=<webhook-signing-secret>
NEXTAUTH_SECRET=<session-signing-key>
DATABASE_URL=<postgres-connection-string>
ENCRYPTION_KEY=<aes-256-key-for-tokens>
# Client-safe configuration (public information only)
NEXT_PUBLIC_APP_URL=https://app.merchops.ai
NEXT_PUBLIC_SHOPIFY_APP_HANDLE=merchops-betaValidation:
MerchOps provides comprehensive environment validation at /apps/web/lib/env.ts:
- All secrets validated at startup via Zod schema
- Application fails fast if required secrets missing
- Type-safe access prevents accidental exposure
- Strict separation of server-only and client-safe variables
- Automatic type inference for environment variables
Usage Example:
import { serverEnv, clientEnv, validateEnv } from '@/lib/env';
// Validate on application startup
validateEnv();
// Type-safe access to server-only secrets
const shopifySecret = serverEnv.SHOPIFY_API_SECRET;
const encryptionKey = serverEnv.ENCRYPTION_KEY;
// Client-safe variables (exposed to browser)
const appUrl = clientEnv.NEXT_PUBLIC_APP_URL;Algorithm: AES-256-GCM (authenticated encryption)
Implementation:
MerchOps provides comprehensive cryptography utilities at /apps/web/lib/crypto.ts:
- AES-256-GCM authenticated encryption for tokens
- Unique IV generation for each encryption operation
- Authentication tag validation to prevent tampering
- Secure idempotency key generation
- Timing-safe comparison functions
- HMAC utilities for message authentication
Usage Example:
import { encryptToken, decryptToken, generateIdempotencyKey } from '@/lib/crypto';
import { serverEnv } from '@/lib/env';
// Encrypt access token before storing
const encryptedToken = encryptToken(
shopifyAccessToken,
serverEnv.ENCRYPTION_KEY
);
await prisma.shopifyConnection.create({
data: {
workspaceId,
accessTokenEncrypted: encryptedToken,
},
});
// Decrypt token when needed for API calls
const connection = await prisma.shopifyConnection.findUnique({
where: { workspaceId },
});
const accessToken = decryptToken(
connection.accessTokenEncrypted,
serverEnv.ENCRYPTION_KEY
);
// Generate idempotency key for action execution
const idempotencyKey = generateIdempotencyKey('discount');Storage Pattern:
// Store encrypted token in database
async function storeAccessToken(workspaceId: string, accessToken: string): Promise<void> {
const encryptedToken = encrypt(accessToken, env.ENCRYPTION_KEY);
await prisma.shopifyConnection.upsert({
where: { workspaceId },
update: { accessTokenEncrypted: encryptedToken },
create: { workspaceId, accessTokenEncrypted: encryptedToken }
});
}
// Retrieve and decrypt token for API calls
async function getAccessToken(workspaceId: string): Promise<string> {
const connection = await prisma.shopifyConnection.findUnique({
where: { workspaceId },
select: { accessTokenEncrypted: true }
});
if (!connection) throw new NotFoundError('Shopify connection not found');
return decrypt(connection.accessTokenEncrypted, env.ENCRYPTION_KEY);
}Rotation Schedule:
ENCRYPTION_KEY: Rotate every 90 daysNEXTAUTH_SECRET: Rotate every 90 daysSHOPIFY_WEBHOOK_SECRET: Rotate on security incident or annually- Access tokens: Refresh via OAuth token exchange every 24 hours (if using online tokens)
Rotation Process:
- Generate new encryption key
- Decrypt all tokens with old key
- Re-encrypt with new key
- Update environment variable
- Deploy with zero downtime (rolling restart)
- Archive old key for 30-day recovery window
Migration Script:
async function rotateEncryptionKey(oldKey: string, newKey: string): Promise<void> {
const connections = await prisma.shopifyConnection.findMany({
select: { id: true, accessTokenEncrypted: true }
});
for (const conn of connections) {
const plaintext = decrypt(conn.accessTokenEncrypted, oldKey);
const reEncrypted = encrypt(plaintext, newKey);
await prisma.shopifyConnection.update({
where: { id: conn.id },
data: { accessTokenEncrypted: reEncrypted }
});
}
logger.info('Encryption key rotation completed', { count: connections.length });
}Logging Rules:
- Sanitize all log output before writing
- Redact known secret patterns (tokens, keys, passwords)
- Never log request bodies containing secrets
- Audit logs for accidental exposure regularly
Safe Logging Pattern:
import pino from 'pino';
const logger = pino({
// Custom serializer to redact secrets
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
// NEVER log headers containing Authorization or API keys
headers: sanitizeHeaders(req.headers),
}),
err: pino.stdSerializers.err,
},
});
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
const safe = { ...headers };
const sensitiveKeys = ['authorization', 'x-shopify-access-token', 'cookie'];
for (const key of sensitiveKeys) {
if (safe[key]) {
safe[key] = '[REDACTED]';
}
}
return safe;
}Core Requirement: Every database query MUST include workspace_id filter to prevent cross-tenant data leakage.
Implementation:
MerchOps provides comprehensive workspace scoping middleware at /apps/web/server/middleware/workspace-scope.ts:
- Automatic workspaceId injection into all database queries
- Prevents accidental cross-tenant data access
- Next.js route handler wrapper for easy integration
- Workspace access validation utilities
- Test helpers for verifying tenant isolation
Usage in API Routes:
import { withWorkspaceScope } from '@/server/middleware/workspace-scope';
// Automatically scoped to authenticated workspace
export const GET = withWorkspaceScope(async (request, context) => {
const { db, workspaceId } = context;
// All queries are automatically scoped to workspaceId
const opportunities = await db.opportunity.findMany({
where: { state: 'new' },
// workspaceId filter injected automatically
});
return Response.json(opportunities);
});Manual Scoping:
import { getWorkspaceScopedClient } from '@/server/middleware/workspace-scope';
export async function getOpportunities(workspaceId: string) {
const db = getWorkspaceScopedClient(workspaceId);
// All queries automatically include workspaceId filter
const opportunities = await db.opportunity.findMany();
await db.$disconnect();
return opportunities;
}Schema Enforcement:
model Event {
id String @id @default(cuid())
workspaceId String
type String
payload Json
occurredAt DateTime
dedupeKey String
createdAt DateTime @default(now())
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, dedupeKey])
@@index([workspaceId, occurredAt])
}
model Opportunity {
id String @id @default(cuid())
workspaceId String
type String
priority String
state String
createdAt DateTime @default(now())
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([workspaceId, state, priority])
}Key Patterns:
- Foreign key constraints enforce referential integrity
- Composite unique constraints include
workspaceId - Cascade deletes ensure workspace deletion removes all child data
- Indexes optimize workspace-scoped queries
Multi-Tenant Test Suite:
// tests/security/tenant-isolation.test.ts
describe('Tenant Isolation', () => {
let workspace1: Workspace;
let workspace2: Workspace;
beforeEach(async () => {
workspace1 = await createTestWorkspace();
workspace2 = await createTestWorkspace();
});
test('cannot read events from other workspace', async () => {
// Create event in workspace1
await createEvent({ workspaceId: workspace1.id, type: 'test' });
// Query from workspace2 context
const prisma = getPrismaClientForWorkspace(workspace2.id);
const events = await prisma.event.findMany();
expect(events).toHaveLength(0); // Should not see workspace1 events
});
test('cannot update opportunities in other workspace', async () => {
// Create opportunity in workspace1
const opp = await createOpportunity({ workspaceId: workspace1.id });
// Attempt update from workspace2 context
const prisma = getPrismaClientForWorkspace(workspace2.id);
await expect(
prisma.opportunity.update({
where: { id: opp.id },
data: { state: 'dismissed' },
})
).rejects.toThrow(); // Should fail due to workspace_id mismatch
});
test('raw SQL queries must include workspace filter', async () => {
// Anti-pattern: raw query without workspace filter
const dangerousQuery = prisma.$queryRaw`
SELECT * FROM events WHERE type = 'order_created'
`;
// This test should FAIL in code review
// All raw queries must explicitly filter by workspace_id
});
});Code Review Checklist:
- Every Prisma query includes explicit or middleware-injected
workspaceId - No raw SQL queries bypass workspace filter
- API routes extract
workspaceIdfrom authenticated session - Background jobs receive
workspaceIdas job payload parameter
Session Strategy: JWT-based sessions with secure storage and CSRF protection.
Configuration:
// apps/web/server/auth/auth-config.ts
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
export const authOptions = {
providers: [
CredentialsProvider({
name: 'Email',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const user = await verifyCredentials(credentials);
if (user) {
return { id: user.id, email: user.email, workspaceId: user.workspaceId };
}
return null;
},
}),
],
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.workspaceId = user.workspaceId;
}
return token;
},
async session({ session, token }) {
session.user.workspaceId = token.workspaceId;
return session;
},
},
pages: {
signIn: '/login',
error: '/auth/error',
},
};Mechanism: NextAuth automatically includes CSRF tokens in all state-changing requests.
Implementation:
- Server generates unique CSRF token per session
- Token embedded in forms and API requests
- Server validates token on POST/PUT/DELETE requests
- Mismatched tokens result in 403 Forbidden
Example:
// Automatic in NextAuth, but for custom routes:
import { getCsrfToken } from 'next-auth/react';
async function submitAction(payload: ActionPayload) {
const csrfToken = await getCsrfToken();
const response = await fetch('/api/actions/approve', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify(payload),
});
return response.json();
}Policy:
- Active session: 30 days with sliding window
- Inactive timeout: 7 days without activity
- Hard expiry: Force re-authentication after 90 days
Validation:
export async function validateSession(req: Request): Promise<Session> {
const session = await getServerSession(authOptions);
if (!session) {
throw new UnauthenticatedError('No active session');
}
// Check last activity timestamp
const lastActivity = session.user.lastActivityAt;
const inactiveThreshold = 7 * 24 * 60 * 60 * 1000; // 7 days
if (Date.now() - lastActivity > inactiveThreshold) {
await signOut();
throw new SessionExpiredError('Session expired due to inactivity');
}
// Update last activity
await updateLastActivity(session.user.id);
return session;
}Rule: Only non-sensitive configuration may use NEXT_PUBLIC_ prefix.
Allowed:
NEXT_PUBLIC_APP_URL=https://app.merchops.ai
NEXT_PUBLIC_SHOPIFY_APP_HANDLE=merchops-beta
NEXT_PUBLIC_SENTRY_DSN=<public-sentry-dsn>Prohibited:
# NEVER DO THIS
NEXT_PUBLIC_SHOPIFY_CLIENT_SECRET=<secret> # ❌ Exposed in bundle
NEXT_PUBLIC_DATABASE_URL=<url> # ❌ Exposed in bundle
NEXT_PUBLIC_ENCRYPTION_KEY=<key> # ❌ Exposed in bundlePattern: Use server-only package to enforce separation.
Installation:
pnpm add server-onlyUsage:
// apps/web/lib/server/shopify-api.ts
import 'server-only'; // Throws error if imported on client
export async function fetchShopifyData(workspaceId: string) {
const accessToken = await getAccessToken(workspaceId); // Safe: server-only
// ... Shopify API call
}Client Import Attempt:
// apps/web/components/ProductList.tsx
import { fetchShopifyData } from '@/lib/server/shopify-api'; // ❌ Build fails!Error Message:
Error: "server-only" module cannot be imported from client code.
Automated Check: CI pipeline verifies no secrets in client bundle.
Script:
#!/bin/bash
# scripts/verify-bundle-security.sh
echo "Verifying client bundle security..."
# Build the app
pnpm build
# Search for common secret patterns in client bundles
BUNDLE_DIR=".next/static"
# Patterns to detect (base64-encoded secrets, API keys, etc.)
PATTERNS=(
"SHOPIFY_CLIENT_SECRET"
"DATABASE_URL"
"ENCRYPTION_KEY"
"NEXTAUTH_SECRET"
"sk_[a-zA-Z0-9]{32}" # Stripe secret key pattern
"xoxb-[0-9]+-[0-9]+" # Slack token pattern
)
FOUND=0
for pattern in "${PATTERNS[@]}"; do
if grep -r "$pattern" "$BUNDLE_DIR" > /dev/null 2>&1; then
echo "❌ SECURITY ERROR: Found secret pattern in bundle: $pattern"
FOUND=1
fi
done
if [ $FOUND -eq 1 ]; then
echo "❌ Build verification failed: secrets detected in client bundle"
exit 1
fi
echo "✅ Build verification passed: no secrets in client bundle"CI Integration:
# .github/workflows/ci.yml
- name: Security - Verify Bundle Safety
run: |
pnpm build
./scripts/verify-bundle-security.shThreat: Users accessing resources outside their workspace or privilege level.
Mitigation:
- Prisma middleware enforces
workspace_idon all queries (see Tenant Isolation) - Server-side authorization checks on every route
- No client-side access control logic
- Admin actions require separate elevated session
Implementation:
export async function authorizeAction(session: Session, resourceId: string): Promise<void> {
const resource = await prisma.actionDraft.findUnique({
where: { id: resourceId },
select: { workspaceId: true },
});
if (!resource || resource.workspaceId !== session.user.workspaceId) {
throw new ForbiddenError('Access denied');
}
}Threat: Weak encryption, exposed secrets, insecure storage.
Mitigation:
- AES-256-GCM for token encryption at rest
- TLS 1.3 for all transit (enforced via CDN/load balancer)
- HTTPS-only cookies with Secure and HttpOnly flags
- No client-side cryptography for secrets
Implementation:
// Session cookie configuration
cookies: {
sessionToken: {
name: '__Secure-next-auth.session-token',
options: {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'lax',
path: '/',
},
},
}Threat: SQL injection, command injection, template injection.
Mitigation:
- Prisma ORM with parameterized queries (no raw SQL)
- Zod input validation on all user inputs
- Content Security Policy headers
- No dynamic code execution functions
Implementation:
// Validate all inputs with Zod
import { z } from 'zod';
const CreateDiscountSchema = z.object({
title: z.string().min(1).max(255),
percentage: z.number().min(0).max(100),
startsAt: z.string().datetime(),
endsAt: z.string().datetime(),
});
export async function createDiscount(input: unknown) {
const validated = CreateDiscountSchema.parse(input); // Throws if invalid
// Safe to use validated data
}Threat: Fundamental design flaws, lack of threat modeling.
Mitigation:
- Explicit approval required for all actions (no auto-execution)
- Immutable audit logs for all state changes
- Rate limiting on sensitive endpoints
- Graceful degradation on external service failures
Design Principle:
- "Calm over clever" - predictable, auditable behavior
- "Control over automation" - user always in command loop
Threat: Default credentials, verbose errors, unnecessary features enabled.
Mitigation:
- Environment variables validated at startup (fail fast)
- Production error messages sanitized (no stack traces to client)
- Security headers configured via middleware
- Unnecessary services disabled (e.g., no GraphQL introspection in prod)
Implementation:
// apps/web/middleware.ts
import { NextResponse } from 'next/server';
export function middleware(request: Request) {
const response = NextResponse.next();
// Security headers
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
);
return response;
}Threat: Known vulnerabilities in dependencies.
Mitigation:
- Automated dependency scanning via Dependabot
- Weekly dependency audit schedule
- Pinned dependency versions in package.json
- Snyk or npm audit in CI pipeline
CI Integration:
- name: Security - Audit Dependencies
run: pnpm audit --audit-level=highThreat: Weak passwords, credential stuffing, session hijacking.
Mitigation:
- NextAuth with bcrypt password hashing (cost factor 12)
- Rate limiting on login endpoints (5 attempts per 15 minutes)
- Session tokens rotated on privilege escalation
- Account lockout after repeated failed attempts
Implementation:
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}Threat: Unsigned updates, insecure CI/CD, tampered data.
Mitigation:
- Webhook HMAC verification (see Webhook Verification section)
- Immutable event store prevents retroactive tampering
- Audit logs for all data modifications
- Dependency verification via lock files (pnpm-lock.yaml)
Integrity Check:
// Verify event has not been tampered with
export async function verifyEventIntegrity(event: Event): Promise<boolean> {
const computedHash = crypto
.createHash('sha256')
.update(JSON.stringify(event.payload) + event.occurredAt.toISOString())
.digest('hex');
return computedHash === event.integrityHash;
}Threat: Insufficient logging, no alerting on security events.
Mitigation:
- Structured logging with Pino (all security events logged)
- Sentry for error tracking and alerting
- Correlation IDs for request tracing
- Audit log for all privileged actions
Logging Pattern:
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: { service: 'merchops-api' },
});
export function logSecurityEvent(event: SecurityEvent): void {
logger.warn({
type: 'security_event',
event: event.type,
workspaceId: event.workspaceId,
userId: event.userId,
ipAddress: event.ipAddress,
timestamp: new Date().toISOString(),
});
}Threat: Attacker triggers requests to internal services.
Mitigation:
- Shopify API calls via official SDK (no user-controlled URLs)
- Webhook URLs validated and whitelisted
- No arbitrary URL fetching based on user input
- Network segmentation (database not exposed to public internet)
URL Validation:
const ALLOWED_WEBHOOK_DOMAINS = ['shopify.com', 'myshopify.com'];
export function validateWebhookUrl(url: string): boolean {
const parsed = new URL(url);
return ALLOWED_WEBHOOK_DOMAINS.some((domain) => parsed.hostname.endsWith(domain));
}Key Metrics:
- Failed authentication attempts per IP
- Invalid webhook signatures
- Cross-workspace query attempts (should be zero)
- Unusual API rate spikes
- Token refresh failures
Alerting:
- Sentry for application errors
- CloudWatch/Datadog for infrastructure metrics
- PagerDuty for critical security incidents
Detection:
- Monitor alerts for security events
- Investigate anomalies via correlation IDs
- Query audit logs for affected resources
Containment:
- Revoke compromised access tokens immediately
- Invalidate user sessions if account compromised
- Rate limit or block malicious IPs
Recovery:
- Rotate affected secrets (encryption keys, webhook secrets)
- Notify affected workspaces
- Restore from backups if data integrity compromised
Post-Incident:
- Document incident timeline
- Update security controls based on lessons learned
- Conduct blameless retrospective
- Access tokens: Encrypted at rest, deleted on OAuth revocation
- Event logs: Retained indefinitely (immutable audit trail)
- User sessions: Expired after 30 days
- Webhook payloads: Retained for 90 days
- Customer data retrieved from Shopify as needed, not stored long-term
- Email addresses for win-back campaigns fetched at execution time
- No payment card data stored (violates PCI-DSS scope)
Every security-relevant action logged:
- OAuth grants and revocations
- Action approvals and executions
- Workspace access (who viewed what, when)
- Configuration changes
Query Example:
SELECT * FROM audit_logs
WHERE workspace_id = 'ws_abc123'
AND action_type = 'execution_approved'
AND created_at > NOW() - INTERVAL '30 days'
ORDER BY created_at DESC;Pre-Launch Verification:
- All Shopify webhooks verified via HMAC-SHA256
- Access tokens encrypted at rest with AES-256-GCM
- No secrets in client bundle (verified via automated script)
- Tenant isolation enforced via Prisma middleware
- Multi-tenant test suite passes (no cross-workspace access)
- CSRF protection enabled on all state-changing routes
- Rate limiting configured on authentication endpoints
- Security headers configured (CSP, X-Frame-Options, etc.)
- Dependency audit clean (no high/critical vulnerabilities)
- Sentry error tracking configured
- Audit logging enabled for all privileged actions
- Incident response runbook documented
- Key rotation procedures tested
MerchOps provides production-grade security utilities located in the following files:
Functions:
verifyShopifyWebhook(rawBody, signature, secret)- Basic HMAC-SHA256 verificationverifyWebhookSecure(rawBody, headers, secret)- Comprehensive verification with replay protectionverifyWebhookTimestamp(timestamp, maxAgeSeconds)- Prevent replay attacksextractWebhookSignature(headers)- Safe signature extractiongenerateWebhookDedupeKey(payload, workspaceId, topic)- Idempotent processing
Usage:
import { verifyWebhookSecure } from '@/server/shopify/verify-webhook';
const result = verifyWebhookSecure(rawBody, headers, env.SHOPIFY_API_SECRET);
if (!result.verified) {
return new Response('Unauthorized', { status: 401 });
}Exports:
serverEnv- Validated server-only environment variablesclientEnv- Validated client-safe environment variablesvalidateEnv()- Startup validation functionisDevelopment(),isProduction(),isTest()- Environment checks
Usage:
import { serverEnv, validateEnv } from '@/lib/env';
validateEnv(); // Call at startup
const shopifySecret = serverEnv.SHOPIFY_API_SECRET;
const encryptionKey = serverEnv.ENCRYPTION_KEY;Functions:
encryptToken(token, key)- AES-256-GCM encryption for secretsdecryptToken(encrypted, key)- Authenticated decryptiongenerateIdempotencyKey(prefix)- Secure idempotency keysgenerateSecureToken(length)- Cryptographic random tokenshashValue(value)- SHA-256 hashing for dedupe keyscreateHmac(message, secret)- HMAC generationverifyHmac(message, signature, secret)- Timing-safe HMAC verificationconstantTimeEqual(a, b)- Timing-safe string comparisonredactSensitive(value, visibleChars)- Safe logging
Usage:
import { encryptToken, decryptToken, generateIdempotencyKey } from '@/lib/crypto';
import { serverEnv } from '@/lib/env';
// Encrypt token before storage
const encrypted = encryptToken(accessToken, serverEnv.ENCRYPTION_KEY);
// Decrypt for API calls
const decrypted = decryptToken(encrypted, serverEnv.ENCRYPTION_KEY);
// Generate idempotency key
const key = generateIdempotencyKey('email');Functions:
getWorkspaceScopedClient(workspaceId)- Returns auto-scoped Prisma clientwithWorkspaceScope(handler)- Next.js route handler wrappervalidateWorkspaceAccess(userId, workspaceId, db)- Access validationverifyResourceWorkspace(resourceId, workspaceId, model, db)- Resource verificationtestWorkspaceIsolation(workspaceA, workspaceB, model, data)- Test helper
Usage:
import { withWorkspaceScope } from '@/server/middleware/workspace-scope';
export const GET = withWorkspaceScope(async (request, context) => {
const { db, workspaceId } = context;
// All queries automatically scoped to workspaceId
const opportunities = await db.opportunity.findMany();
return Response.json(opportunities);
});Test Coverage:
- Event isolation across workspaces
- Opportunity isolation
- Action draft isolation
- Execution isolation
- Workspace access validation
- Resource ownership verification
- Query function enforcement (findMany, count, aggregate)
Running Tests:
# Run all security tests
pnpm test:integration tests/integration/security
# Run tenant isolation specifically
pnpm test:integration tests/integration/security/tenant-isolation.test.tsFor security concerns or to report vulnerabilities:
- Email: security@merchops.ai
- Escalation: security-incidents Slack channel
Responsible Disclosure Policy: We appreciate responsible disclosure of security issues. Report vulnerabilities privately before public disclosure.