Headless Commerce January 28, 2026 14 min read

Security Hardening a Headless Shopify Frontend

When you go headless, you own the frontend. That means you own the security surface. Here is every security decision we make on a headless Shopify build, from CSP headers to webhook HMAC validation, with the actual code behind each one.

Tyler Colby · Founder, Colby's Data Movers

You Are Now Responsible

On a standard Shopify store, Shopify handles frontend security. They set the headers. They sanitize inputs. They manage the SSL certificate. They handle PCI compliance for checkout.

When you go headless, Shopify still handles checkout security (the checkout is still on their domain). But everything before checkout is your responsibility. The product pages, collection pages, cart, search, AI features, API routes, webhook endpoints. All yours.

This is not a reason to avoid headless. It is a reason to do it correctly from the start. Here is exactly how.

Content Security Policy Headers

CSP is your first line of defense against XSS attacks. It tells the browser which sources of content are allowed to execute on your pages. Without CSP, any injected script will run. With a strict CSP, injected scripts are blocked before they execute.

Here is the CSP we deploy on every headless Shopify build:

// next.config.js
const securityHeaders = [
  {
    key: 'Content-Security-Policy',
    value: [
      "default-src 'self'",
      "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.shopify.com https://www.googletagmanager.com",
      "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
      "font-src 'self' https://fonts.gstatic.com",
      "img-src 'self' https://cdn.shopify.com https://*.cloudfront.net data: blob:",
      "connect-src 'self' https://*.myshopify.com https://*.shopify.com https://api.openai.com",
      "frame-src 'none'",
      "object-src 'none'",
      "base-uri 'self'",
      "form-action 'self' https://*.myshopify.com",
      "frame-ancestors 'none'",
    ].join('; '),
  },
  {
    key: 'X-Frame-Options',
    value: 'DENY',
  },
  {
    key: 'X-Content-Type-Options',
    value: 'nosniff',
  },
  {
    key: 'Referrer-Policy',
    value: 'strict-origin-when-cross-origin',
  },
  {
    key: 'Permissions-Policy',
    value: 'camera=(), microphone=(), geolocation=()',
  },
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=63072000; includeSubDomains; preload',
  },
];

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders,
      },
    ];
  },
};

Let me explain the decisions in this policy.

script-src 'unsafe-inline' is unfortunate but necessary. Next.js injects inline scripts for hydration data. You can use nonce-based CSP with Next.js middleware, but it adds complexity and breaks caching on some CDNs. For most e-commerce stores, the trade-off is not worth it. The combination of CSP plus the other mitigations below provides sufficient protection.

connect-src explicitly lists every API domain your frontend communicates with. Shopify's Storefront API. OpenAI for the AI assistant. Nothing else. If an attacker injects a script that tries to exfiltrate data to evil.com, the browser blocks the request.

frame-src 'none' and frame-ancestors 'none' prevent your site from being embedded in an iframe. This blocks clickjacking attacks entirely.

object-src 'none' prevents Flash and other plugin-based attacks. These are rare now but the header costs nothing.

XSS Prevention: The Grammarly Problem

Here is a real attack vector I found on a headless Shopify store. The product descriptions in Shopify admin contained malicious HTML. Not from an attacker. From Grammarly.

Merchants edit product descriptions in Shopify's rich text editor. Many of them have Grammarly installed. Grammarly injects span tags, data attributes, and sometimes script-adjacent markup into the text as the merchant types. This markup gets saved to Shopify's database.

When the headless frontend renders the product description using dangerouslySetInnerHTML, that markup executes in the browser. In most cases, it is harmless Grammarly garbage. But the same vector could be exploited by anyone with admin access to inject actual malicious scripts.

// BAD: Raw HTML from Shopify rendered directly
function ProductDescription({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

// GOOD: Sanitize HTML before rendering
import sanitizeHtml from 'sanitize-html';

const ALLOWED_TAGS = [
  'p', 'br', 'strong', 'em', 'ul', 'ol', 'li',
  'h2', 'h3', 'h4', 'a', 'img', 'table', 'thead',
  'tbody', 'tr', 'th', 'td', 'blockquote', 'pre', 'code',
];

const ALLOWED_ATTRIBUTES = {
  'a': ['href', 'title', 'target', 'rel'],
  'img': ['src', 'alt', 'width', 'height'],
  'td': ['colspan', 'rowspan'],
  'th': ['colspan', 'rowspan'],
};

function sanitizeProductHtml(html) {
  return sanitizeHtml(html, {
    allowedTags: ALLOWED_TAGS,
    allowedAttributes: ALLOWED_ATTRIBUTES,
    allowedSchemes: ['https'],
    transformTags: {
      'a': (tagName, attribs) => ({
        tagName,
        attribs: {
          ...attribs,
          rel: 'noopener noreferrer',
          target: attribs.target || '_self',
        },
      }),
    },
  });
}

function ProductDescription({ html }) {
  const clean = sanitizeProductHtml(html);
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

The allowlist approach is critical. You specify exactly which tags and attributes are permitted. Everything else is stripped. No script tags. No event handlers. No data URIs in images (which can execute JavaScript). No javascript: protocol in links.

Run this sanitization on the server side during rendering, not on the client. If you sanitize on the client, the browser has already parsed the raw HTML by the time your sanitizer runs.

Rate Limiting on API Routes

Your headless storefront has API routes. Revalidation endpoints. Contact forms. AI assistant endpoints. Without rate limiting, these are open to abuse.

The AI assistant endpoint is the most expensive target. Each request costs money (OpenAI tokens). An attacker running a loop against it can rack up hundreds of dollars in API costs in minutes.

// lib/rate-limit.ts
import { LRUCache } from 'lru-cache';

type RateLimitOptions = {
  interval: number;   // Time window in ms
  uniqueTokenPerInterval: number;  // Max users per interval
  limit: number;      // Max requests per user per interval
};

export function rateLimit(options: RateLimitOptions) {
  const cache = new LRUCache({
    max: options.uniqueTokenPerInterval,
    ttl: options.interval,
  });

  return {
    check: (token: string): { success: boolean; remaining: number } => {
      const current = (cache.get(token) as number) || 0;

      if (current >= options.limit) {
        return { success: false, remaining: 0 };
      }

      cache.set(token, current + 1);
      return { success: true, remaining: options.limit - current - 1 };
    },
  };
}

// Usage in API route
const aiLimiter = rateLimit({
  interval: 60 * 1000,          // 1 minute window
  uniqueTokenPerInterval: 500,   // Track up to 500 IPs
  limit: 5,                      // 5 requests per minute per IP
});

export default async function handler(req, res) {
  const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
  const { success, remaining } = aiLimiter.check(ip);

  res.setHeader('X-RateLimit-Remaining', remaining);

  if (!success) {
    return res.status(429).json({
      error: 'Too many requests. Try again in a minute.',
    });
  }

  // Process AI request...
}

We set different limits for different endpoints:

Rate limits by endpoint:
  /api/ai/ask:          5 requests/minute (expensive)
  /api/contact:         3 requests/minute (prevent spam)
  /api/revalidate:      10 requests/minute (webhook bursts)
  /api/newsletter:      2 requests/minute (prevent abuse)
  /api/cart/*:          30 requests/minute (legitimate use)

For production deployments, use Vercel's Edge Middleware or Cloudflare's rate limiting instead of in-memory LRU. In-memory rate limiting does not work across multiple serverless instances. Edge rate limiting operates at the CDN layer before the request hits your function.

Webhook Signature Validation

Shopify webhooks are how your headless store stays synchronized with the Shopify backend. Product updates, order creations, inventory changes. These webhooks hit your API routes.

Without signature validation, anyone can send fake webhook payloads to your endpoints. They can trigger cache revalidation with garbage data, create fake order records, or manipulate your inventory display.

// lib/shopify-webhook.ts
import crypto from 'crypto';

const SHOPIFY_WEBHOOK_SECRET = process.env.SHOPIFY_WEBHOOK_SECRET;

export function verifyShopifyWebhook(
  rawBody: string | Buffer,
  hmacHeader: string
): boolean {
  if (!SHOPIFY_WEBHOOK_SECRET) {
    throw new Error('SHOPIFY_WEBHOOK_SECRET not configured');
  }

  const digest = crypto
    .createHmac('sha256', SHOPIFY_WEBHOOK_SECRET)
    .update(rawBody)
    .digest('base64');

  // Timing-safe comparison prevents timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(digest),
    Buffer.from(hmacHeader)
  );
}

// API route with webhook validation
export const config = {
  api: {
    bodyParser: false, // Required: must access raw body for HMAC
  },
};

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  // Read raw body for HMAC verification
  const chunks = [];
  for await (const chunk of req) {
    chunks.push(chunk);
  }
  const rawBody = Buffer.concat(chunks);

  const hmac = req.headers['x-shopify-hmac-sha256'];

  if (!hmac || !verifyShopifyWebhook(rawBody, hmac)) {
    console.error('Webhook signature verification failed');
    return res.status(401).json({ error: 'Unauthorized' });
  }

  const payload = JSON.parse(rawBody.toString());
  // Process verified webhook...

  return res.status(200).json({ received: true });
}

Two critical details. First, you must disable Next.js body parsing for webhook routes. If the body is parsed before you can compute the HMAC, the raw bytes are lost and the signature will never match. Second, use crypto.timingSafeEqual for the comparison. A standard string comparison leaks timing information that can be used to forge signatures.

Environment Variable Hygiene

Next.js has a convention that trips up developers: any environment variable prefixed with NEXT_PUBLIC_ is embedded in the client-side JavaScript bundle. It is visible to anyone who opens DevTools.

// .env.local

# SAFE: Server-only. Never sent to browser.
SHOPIFY_ADMIN_API_TOKEN=shpat_xxxxxxxxxxxxx
SHOPIFY_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
OPENAI_API_KEY=sk-xxxxxxxxxxxxx
HUBSPOT_API_KEY=pat-xxxxxxxxxxxxx

# SAFE: Public by design. Read-only, scoped to storefront.
NEXT_PUBLIC_SHOPIFY_STOREFRONT_TOKEN=xxxxxxxxxx
NEXT_PUBLIC_SHOPIFY_DOMAIN=your-store.myshopify.com
NEXT_PUBLIC_SITE_URL=https://yourstore.com

# DANGEROUS: Would expose secret to all visitors
# NEXT_PUBLIC_OPENAI_API_KEY=sk-xxxxx     <-- NEVER DO THIS
# NEXT_PUBLIC_ADMIN_TOKEN=shpat_xxxxx      <-- NEVER DO THIS

The Shopify Storefront Access Token is safe to expose. It is designed to be public. It has read-only access to published products and cannot modify anything. Shopify explicitly documents this.

The Shopify Admin API Token is not safe to expose. It can read and write orders, customers, products, and everything else in your store. If this leaks, an attacker has full access to your Shopify admin.

We enforce this with a build-time check:

// scripts/check-env.js
// Runs before every build and in CI/CD

const DANGEROUS_PREFIXES = [
  'SHOPIFY_ADMIN',
  'OPENAI_API',
  'HUBSPOT_API',
  'WEBHOOK_SECRET',
  'DATABASE_',
  'REDIS_',
];

const publicVars = Object.keys(process.env)
  .filter(key => key.startsWith('NEXT_PUBLIC_'));

const violations = publicVars.filter(key =>
  DANGEROUS_PREFIXES.some(prefix =>
    key.replace('NEXT_PUBLIC_', '').startsWith(prefix)
  )
);

if (violations.length > 0) {
  console.error('SECURITY VIOLATION: Secrets exposed as public env vars:');
  violations.forEach(v => console.error(`  ${v}`));
  process.exit(1);
}

This script catches the mistake before it reaches production. It runs in CI/CD as a required check. If anyone accidentally prefixes a secret with NEXT_PUBLIC_, the build fails.

CORS on AI Endpoints

If your headless store has an AI assistant that calls OpenAI or Anthropic, the API key lives on your server. The frontend sends a request to your API route, your server calls the AI provider, and returns the response. The AI key never touches the browser.

But your API route needs CORS protection. Without it, any website can call your AI endpoint and burn your tokens.

// middleware.ts
import { NextResponse } from 'next/server';

const ALLOWED_ORIGINS = [
  'https://yourstore.com',
  'https://www.yourstore.com',
  process.env.NODE_ENV === 'development' && 'http://localhost:3000',
].filter(Boolean);

export function middleware(request) {
  const origin = request.headers.get('origin');
  const response = NextResponse.next();

  // Only apply CORS to API routes
  if (request.nextUrl.pathname.startsWith('/api/')) {
    if (origin && ALLOWED_ORIGINS.includes(origin)) {
      response.headers.set('Access-Control-Allow-Origin', origin);
      response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
      response.headers.set('Access-Control-Allow-Headers', 'Content-Type');
      response.headers.set('Access-Control-Max-Age', '86400');
    }

    // Handle preflight
    if (request.method === 'OPTIONS') {
      return new NextResponse(null, {
        status: 204,
        headers: response.headers,
      });
    }

    // Block requests with no origin (direct API calls from scripts)
    // Exception: webhooks from Shopify have no origin
    if (!origin && !request.nextUrl.pathname.includes('/webhook')) {
      // Allow server-side requests (no origin header)
      // But log for monitoring
    }
  }

  return response;
}

export const config = {
  matcher: '/api/:path*',
};

The key decision: we do not use Access-Control-Allow-Origin: *. The wildcard allows any website to call your API. Instead, we explicitly list allowed origins. Only requests from your own domain get CORS headers. Everything else is blocked by the browser.

localStorage and Session Security

The headless frontend stores some data in the browser. Cart state. User preferences. Recently viewed products. This data lives in localStorage or sessionStorage.

Never store anything sensitive in localStorage. No tokens. No customer IDs. No email addresses. localStorage is accessible to any JavaScript running on your domain, including any script that passes your CSP.

// What we store in localStorage (safe):
{
  "cart_id": "gid://shopify/Cart/abc123",     // Shopify cart token (public)
  "recently_viewed": ["handle-1", "handle-2"], // Product handles (public)
  "theme": "dark",                              // UI preference
  "dismissed_banner": true                      // UI state
}

// What we NEVER store in localStorage:
// - Customer access tokens
// - Email addresses
// - Order details
// - API keys of any kind
// - Session identifiers

The Shopify cart ID is safe to store. It is a public token that identifies a cart. It cannot be used to access customer data or place orders without going through Shopify's checkout flow.

For authenticated customer sessions (order history, saved addresses), use HTTP-only cookies set by your server. HTTP-only cookies are not accessible to JavaScript. They are sent automatically with requests and cannot be stolen by XSS.

// API route: set HTTP-only session cookie
export default async function loginHandler(req, res) {
  const { email, password } = req.body;

  const { customerAccessToken } = await shopifyClient.customerAccessTokenCreate(
    email,
    password
  );

  // Set HTTP-only cookie. JavaScript cannot read this.
  res.setHeader('Set-Cookie', [
    `customer_token=${customerAccessToken}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400`,
  ]);

  return res.json({ success: true });
}

Dependency Auditing

Your headless frontend has npm dependencies. Each dependency is a potential vulnerability. Run npm audit regularly and address critical and high severity issues immediately.

# Run in CI/CD on every build
npm audit --audit-level=high

# If vulnerabilities are found, update the specific package
npm update sanitize-html

# For transitive dependencies, use overrides in package.json
{
  "overrides": {
    "vulnerable-package": ">=2.1.0"
  }
}

We also pin major versions of critical dependencies (React, Next.js, sanitize-html) and update them deliberately rather than automatically. A compromised npm package update is a supply chain attack that bypasses every other security measure.

The Security Checklist

Headless Shopify Security Checklist:
[ ] CSP headers configured (script-src, connect-src, frame-ancestors)
[ ] X-Frame-Options: DENY
[ ] X-Content-Type-Options: nosniff
[ ] Strict-Transport-Security with preload
[ ] Product description HTML sanitized (sanitize-html)
[ ] User-generated content sanitized before rendering
[ ] Rate limiting on all API routes
[ ] Stricter limits on AI/expensive endpoints
[ ] Webhook HMAC validation with timing-safe comparison
[ ] Raw body preserved for webhook signature verification
[ ] No secrets in NEXT_PUBLIC_ environment variables
[ ] Build-time check for exposed secrets
[ ] CORS restricted to specific origins (no wildcard)
[ ] Sensitive data in HTTP-only cookies, not localStorage
[ ] npm audit runs in CI/CD pipeline
[ ] Dependencies pinned and updated deliberately
[ ] Error messages do not leak internal details
[ ] Logging captures security events (failed auth, rate limits)
[ ] SSL/TLS certificate auto-renewal configured

Security is not a feature you add at the end. It is a set of decisions you make at the beginning and enforce continuously. Every headless storefront we build starts with this checklist before a single product page is rendered. The investment is small. The cost of getting it wrong is not. Questions about securing your headless build? Let's talk.