Crystallize logo

Security headers and credentials for a storefront

A storefront handles shopper sessions, carts and payments, and soon tools that agents call on a shopper's behalf. This page covers the HTTP security headers every storefront should send, a Content Security Policy rolled out in report-only mode first, framing for Crystallize apps, cookies, and where each Crystallize credential may live.

Credentials first

Credential

Where it lives

Never

Access token pair (id and secret)

Server environment variables

In client bundles, NEXT_PUBLIC_* variables or the browser

Shop API JWT

Server memory, refreshed before it expires

In the browser. The Shop API only accepts browser calls from Crystallize origins and localhost anyway.

Discovery static token (when Discovery is restricted)

Server

In the browser, unless you are fine with it being public

Cart id

httpOnly cookie

In localStorage, or in a URL that gets shared or indexed

When Discovery is open, everything it serves is public. Keep confidential prices and data out of it (see The storefront API contract).

Companion headers

Send these on every response. They are cheap and break nothing on a typical storefront:

// next.config.ts
const securityHeaders = [
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), browsing-topics=()' },
  { key: 'Cross-Origin-Opener-Policy', value: 'same-origin-allow-popups' },
  { key: 'X-Frame-Options', value: 'DENY' },
];

export default {
  async headers() {
    return [{ source: '/:path*', headers: securityHeaders }];
  },
};
  • same-origin-allow-popups keeps payment providers that open a popup working. Use same-origin if none of yours do.
  • Some Permissions-Policy features are needed by payment or identity providers (payment for the Payment Request API, publickey-credentials-get for passkeys). Allow the ones your checkout uses.

Framing

X-Frame-Options: DENY and frame-ancestors 'none' stop other sites from framing your pages. Three Crystallize cases need exceptions:

  • A Crystallize app (a page of yours shown inside the Crystallize App) needs frame-ancestors https://app.crystallize.com on its routes. Set it in response headers (in next.config headers() or a proxy), because a page can't set it from a <meta> tag. Scope it to the app's routes and keep 'none' elsewhere.
  • Draft preview in a custom view frames your storefront pages inside the Crystallize App. Allow https://app.crystallize.com on the preview route and on responses to a browser in preview mode only, and set the preview cookie SameSite=None; Secure so it is sent inside the frame (see Preview with custom views).
  • An MCP App (product cards rendered inside an AI assistant) is not framed from your site. The host loads it from a resource your MCP server returns, with its own CSP. List your media host there (see MCP Apps).

Content Security Policy

A strict CSP is the main defence against cross-site scripting, and it matters more once your pages expose tools that agents call with the shopper's session (see WebMCP). Roll it out in report-only mode first, read the reports, then enforce:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-{random}' 'strict-dynamic';
  img-src 'self' https://media.crystallize.com data:;
  connect-src 'self';
  frame-src https://js.stripe.com;
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  require-trusted-types-for 'script';
  report-to csp
  • Add your payment provider's script, frame and connect origins (the example shows Stripe's frame host).
  • img-src needs the Crystallize media host, or your custom media domain.
  • The storefront itself doesn't call Crystallize from the browser, so connect-src doesn't need Crystallize hosts. If your browser code calls an open Discovery API directly, add https://api.crystallize.com.
warning

A nonce and a static shell don't mix

A nonce must be new on every response. A page served from a prerendered or cached shell (Next.js Cache Components with partial prerendering, or any CDN-cached HTML) can't carry one, and forcing a nonce makes every page dynamic. Hashes don't fit streamed inline scripts either. Decide which pages need the strict policy (checkout, account) and render those dynamically, or start with the companion headers and a report-only policy while you choose.

Cookies

Name first-party session and cart cookies with the __Host- prefix and set Secure, HttpOnly, SameSite=Lax and Path=/. The prefix makes the browser refuse the cookie unless it is secure, set from your own host, and not scoped to a subdomain.

jar.set('__Host-cart', cart.id, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30 });

Webhooks and routes Crystallize calls

  • Check a shared secret on every webhook route (cache revalidation, payment callbacks). Keep the secret in a header, not in the URL, where it ends up in logs.
  • Payment provider webhooks verify the provider's signature before an order is created. See Payment Gateways.
  • Requests from Crystallize apps and webhooks can be verified with Signature Verification.