Skip to content

Customer Privacy banner broken on storefronts with a checkout subdomain (dot-prefixed storefrontRootDomain used as URL hostname) #3761

@paul-phan

Description

@paul-phan

Summary

On Hydrogen storefronts with a checkout subdomain (the standard setup — storefront on the apex, checkout on a subdomain like checkout.mystore.com), the Customer Privacy banner is fundamentally broken:

  • The hosted banner UI (storefront-banner.js) and the core SDK (consent-tracking-api.js, v0.1 and v0.2) both build an SFAPI URL using Hydrogen's dot-prefixed storefrontRootDomain as a hostname → https://.mystore.com/api/unstable/graphql.jsonERR_NAME_NOT_RESOLVED.
  • The banner script crashes during init → window.Shopify.customerPrivacy.setTrackingConsent / currentVisitorConsent never get installed.
  • The core SDK chains its cookie write after the failed SFAPI fetch's .then()_tracking_consent is never persisted.

User-visible symptoms:

  • Banner reappears on every refresh (the consent decision doesn't stick).
  • Google Consent Mode v2 status reverts from gcs=G111 (granted) → gcs=G100 (denied) on the second page view.
  • window.Shopify.customerPrivacy.setTrackingConsent === undefined post-banner-load.
  • CSP connect-src violation reports for the bogus dot-host URL.

I've verified this on Hydrogen 2026.5.4 against a clean Pilot template clone, and against a production Hydrogen 2026.4 storefront.

Why Hydrogen can't fully fix this from inside the npm package

The dot prefix on storefrontRootDomain is intentional — it's only meant for cookie Domain= scoping so _tracking_consent is readable across subdomains. The bug is that the SDK scripts (hosted on cdn.shopify.com, outside this repo) misuse the same string as a URL hostname.

Hydrogen even has a comment acknowledging the underlying limitation:

// app/dist/development/index.cjs (and equivalent in production bundle)
//
// "Prefix with a dot to ensure this domain is different from checkoutRootDomain.
//  This will ensure old cookies are set for a cross-subdomain checkout setup
//  so that we keep backward compatibility until new cookies are rolled out.
//  Once consent-tracking-api is updated to not rely on cookies anymore, we can
//  remove this."
storefrontRootDomain: commonAncestorDomain ? "." + commonAncestorDomain : void 0,

So Hydrogen knows the SDK has a coupling problem here. But until the SDK is updated, every Hydrogen merchant with a checkout subdomain is shipping a broken consent banner without realising it.

Reproduction

Any Hydrogen 2026.x template + a checkout subdomain:

  1. Clone the Pilot template, configure with PUBLIC_CHECKOUT_DOMAIN=checkout.<apex> where <apex>myshopify.com.

  2. Deploy to a real custom domain (the bug doesn't reproduce on *.myshopify.com because in that case storefrontRootDomain is undefined — commonAncestorDomain returns falsy).

  3. Open the deployed storefront in incognito, click "Accept" on the banner.

  4. Open DevTools → Console. Observe:

    storefront-banner.js:3  POST https://.<apex>/api/unstable/graphql.json net::ERR_NAME_NOT_RESOLVED
    
  5. Open DevTools → Application → Cookies. Observe: no _tracking_consent cookie despite clicking Accept.

  6. Refresh. Banner reappears. GA4 collect URL gcs=G100.

Root cause in the SDK source

cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js

Two call paths build the SFAPI URL:

// Path 1 (works once sameDomainForStorefrontApi: true)
var o = e.granular_consent.checkoutRootDomain || window.location.host
re(o, e)  // → fetch("https://" + o + "/api/unstable/graphql.json", ...)

// Path 2 (BROKEN — uses storefrontRootDomain unconditionally)
re(function(n) {
  return n.granular_consent.storefrontRootDomain || window.location.host
}(e), e)

The re() function:

function re(n, e) {
  // ...
  o = (/^(localhost|127\.0\.0\.1)(:|$)/.test(n) ? "http:" : "https:")
      + "//" + n + "/api/unstable/graphql.json"
  // ...
  i = fetch(o, c)
}

When n === ".mystore.com" (Path 2), the final URL is https://.mystore.com/... → invalid hostname → DNS fails.

cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js

Same anti-pattern exists in the core SDK too:

// storefrontRootDomain used as URL host
storefrontRootDomain || window.location.host

When setTrackingConsent is called (whether by the banner or by user code), the SDK POSTs to the same broken URL. The cookie write is chained inside the .then() of that fetch — so on DNS failure the cookie is never set.

What works today (sameDomainForStorefrontApi: true)

Setting this on the consent config:

consent: {
  // ...
  sameDomainForStorefrontApi: true,
}

fixes checkoutRootDomain (Path 1 above) so the SDK's GraphQL client routes through the storefront's same-domain SFAPI proxy. But it doesn't help with the banner script's Path 2 or the core SDK's cookie-write path, because those use storefrontRootDomain unconditionally, ignoring sameDomainForStorefrontApi.

Workaround we landed downstream

Pilot PR (Weaverse/pilot#387) — full source, ~250 lines net:

  1. withPrivacyBanner: false to skip loading storefront-banner.js entirely. The core SDK still loads.
  2. Custom <ConsentBanner /> component renders on absence of _tracking_consent cookie.
  3. On Accept/Decline, write _tracking_consent directly in the exact serialized format the SDK uses (reverse-engineered from v0.2's S() function: unquoted object keys, JSON.stringify for strings, toString for booleans, omit undefined/empty fields). The Shopify checkout reads this cookie via Domain=.<apex> scoping and honors it.
  4. Dispatch visitorConsentCollected with the standard VisitorConsentCollected shape so downstream listeners (GCM v2 updaters, custom analytics) work unchanged.

This is the minimum viable workaround that keeps the rest of the Customer Privacy contract intact. But every Hydrogen merchant with a subdomain checkout would need to ship something like this — that's a big footgun for a default behaviour.

Suggested fixes

In rough order of how much they'd help:

  1. Fix the SDK scripts on cdn.shopify.com — the canonical fix. Both storefront-banner.js Path 2 and consent-tracking-api.js's SFAPI URL build should derive the host from checkoutRootDomain (or window.location.host if same-domain), never from storefrontRootDomain. That field's purpose is purely cookie scoping. We can't PR this, but presumably someone on the Customer Privacy team can.

  2. Document the bug in @shopify/hydrogen until [Track] Accounts #1 lands. A note in the Customer Privacy guide saying "If your checkout is on a subdomain, withPrivacyBanner: true is currently broken — set it to false and provide your own banner; here's a reference implementation" would save every Hydrogen team weeks of debugging.

  3. Expose a first-class custom-banner pattern in @shopify/hydrogen. Either a <CustomerPrivacy.Banner> component or a useCustomerPrivacyBanner() hook that produces the cookie + event dispatch the way our workaround does, so individual stores don't each reinvent it.

Happy to PR any of (2) or (3) if useful — let us know which would be most helpful to land.

Environment

  • @shopify/hydrogen: 2026.5.4 (verified) + 2026.4.x (also affected)
  • Pilot template: latest main
  • Browser: Chrome 138+ (any modern browser, the failure is at the DNS/CSP layer)
  • Bug present in both consent-tracking-api/v0.1/ and consent-tracking-api/v0.2/ bundles on cdn.shopify.com

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions