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.
Credential | Where it lives | Never |
|---|---|---|
Access token pair (id and secret) | Server environment variables | In client bundles, |
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 |
When Discovery is open, everything it serves is public. Keep confidential prices and data out of it (see The storefront API contract).
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 }];
},
};// 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.payment for the Payment Request API, publickey-credentials-get for passkeys). Allow the ones your checkout uses.X-Frame-Options: DENY and frame-ancestors 'none' stop other sites from framing your pages. Three Crystallize cases need exceptions:
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.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).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 cspContent-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 cspimg-src needs the Crystallize media host, or your custom media domain.connect-src doesn't need Crystallize hosts. If your browser code calls an open Discovery API directly, add https://api.crystallize.com.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.
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 });jar.set('__Host-cart', cart.id, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30 });