Skip to content

fix(consent): bypass Shopify privacy banner SDK on subdomain-checkout setups#387

Merged
hta218 merged 1 commit into
mainfrom
fix/consent-banner-dot-host-bug
May 18, 2026
Merged

fix(consent): bypass Shopify privacy banner SDK on subdomain-checkout setups#387
hta218 merged 1 commit into
mainfrom
fix/consent-banner-dot-host-bug

Conversation

@paul-phan
Copy link
Copy Markdown
Member

Problem

On any Hydrogen storefront with a checkout subdomain (the standard setup — storefront on the apex, checkout on a subdomain like checkout.mystore.com), Shopify's hosted privacy banner is broken:

  • Banner reappears on every refresh
  • Google Consent Mode v2 status reverts G111 (granted) → G100 (denied) on the second page view
  • DevTools shows POST https://.mystore.com/api/unstable/graphql.json net::ERR_NAME_NOT_RESOLVED
  • window.Shopify.customerPrivacy.setTrackingConsent is undefined after the banner script loads

Root cause

Hydrogen passes the Shopify Customer Privacy SDK a config field storefrontRootDomain derived from "." + commonAncestorDomain(checkoutDomain, location.host). The leading dot is intentional and only meant for cookie Domain= scoping so the consent cookie is readable across subdomains.

But both storefront-banner.js and the underlying consent-tracking-api.js (v0.1 and v0.2) take this same string and use it as a URL hostname for an SFAPI POST that records the consent decision:

https://.mystore.com/api/unstable/graphql.json
       ^
       leading dot — invalid hostname → DNS fails

In the banner-script case the SDK init crashes before installing setTrackingConsent / currentVisitorConsent on window.Shopify.customerPrivacy. In the core SDK case the cookie write is chained after the failed fetch's .then(), so _tracking_consent is never persisted either way.

Hydrogen has no public prop to override this. The relevant comment in @shopify/hydrogen/dist/development/index.cjs already acknowledges the underlying limitation:

"Once consent-tracking-api is updated to not rely on cookies anymore, we can remove this."

Fix

withPrivacyBanner: false in the root loader's consent config skips loading the broken banner script. Only the core consent-tracking-api.js loads.

Then a minimal custom <ConsentBanner /> component:

  • Renders on absence of _tracking_consent cookie (the canonical first-visit signal — shouldShowBanner() in v0.2 is gated on a server display_banner flag that Hydrogen never triggers, not interaction state).

  • On Accept / Decline writes _tracking_consent directly with the exact format Shopify expects. Format reverse-engineered from the SDK source — a custom serializer (NOT JSON.stringify): unquoted object keys, JSON.stringify for strings, toString for booleans/numbers, omits undefined and empty-string fields. Example payload:

    {v:"3",con:{CMP:{a:"1",p:"1",m:"1",s:"0"}},cus:{},purposes:{p:false,a:false,m:false,t:false},sale_of_data_region:false,display_banner:false,consent_id:"<uuid>"}
    
  • Sets Domain=.mystore.com so the Shopify checkout reads the same cookie and honors it.

  • Dispatches visitorConsentCollected on window with the same VisitorConsentCollected shape Shopify's SDK emits, so anything listening (Google Consent Mode v2 updaters, custom analytics wiring, etc.) keeps working unchanged.

Reverting when Shopify fixes the SDK

Three steps:

  1. Flip withPrivacyBanner: falsetrue in app/.server/root.ts.
  2. Delete app/components/root/consent-banner.tsx.
  3. Remove the import + mount line in app/root.tsx.

Verification

Tested on a production Hydrogen 2026.4 storefront with the same checkout-subdomain setup:

  • Before: _tracking_consent absent in cookies after clicking Accept; GA4 collect URL gcs=G100 after refresh.
  • After: _tracking_consent written with Domain=.mystore.com, 365-day expiry, format byte-compatible with the SDK's writer; banner does not reappear on refresh; GA4 collect URL stays gcs=G111; Shopify checkout honors the consent.

Files changed

  • app/.server/root.tswithPrivacyBanner: false + a long inline comment documenting why
  • app/components/root/consent-banner.tsx (new) — the custom banner UI + cookie writer
  • app/root.tsx — import + mount the new component inside the existing <Analytics.Provider>

No new dependencies. ~250 lines net.

No behavioural change for same-domain setups

Storefronts where the checkout lives on the same domain (so storefrontRootDomain has no leading dot) never hit the bug, but they will still render the new banner correctly — it's the same UX, just from a different file.

… setups

Shopify's hosted privacy banner script (`storefront-banner.js`) and the
underlying `consent-tracking-api.js` v0.2 SDK have a URL-construction
bug on any Hydrogen storefront that has a checkout subdomain (the
standard Hydrogen setup — storefront on the apex, checkout on a
subdomain).

Hydrogen passes the SDK a config field `storefrontRootDomain` derived
as `"." + commonAncestorDomain(checkoutDomain, location.host)`. The
leading dot is intentional and only meant for cookie `Domain=` scoping
so the consent cookie is readable across subdomains. But both the
banner script and the core SDK take this same string and use it as a
URL hostname for an SFAPI POST to record the consent decision —
producing `https://.mystore.com/api/unstable/graphql.json`.

The leading dot is invalid in a hostname, DNS fails with
`ERR_NAME_NOT_RESOLVED`. In the banner-script case the SDK init crashes
before installing `setTrackingConsent` / `currentVisitorConsent` on
`window.Shopify.customerPrivacy`. In the core SDK case the cookie
write is chained after the failed fetch's `.then()`, so
`_tracking_consent` is never persisted.

User-visible symptom: the banner reappears on every refresh, and
Google Consent Mode v2 status reverts from G111 (granted) to G100
(denied) on the second page view.

Verified by reading the SDK source on cdn.shopify.com (both v0.1 and
v0.2), reproducing in production, and observing
`window.Shopify.customerPrivacy.setTrackingConsent === undefined` after
clicking Accept on the banner. The relevant Hydrogen comment in
`@shopify/hydrogen/dist/development/index.cjs` acknowledges the issue:

  "Once consent-tracking-api is updated to not rely on cookies anymore,
   we can remove this."

Workaround (this commit):

- `withPrivacyBanner: false` in the root loader's consent config.
  Hydrogen now loads only the core consent-tracking-api.js (no banner
  script). `setTrackingConsent` and friends remain available on
  `window.Shopify.customerPrivacy`.
- Render a minimal custom `<ConsentBanner />` styled with Pilot's
  design tokens (`bg-background`, `text-body`, `border-line`,
  `rounded-md`).
- On Accept / Decline, write the `_tracking_consent` cookie directly
  in the exact serialized format Shopify expects. The format was
  reverse-engineered from the SDK source: a custom serializer (NOT
  JSON.stringify) with unquoted object keys, JSON-stringified strings,
  toString'd booleans/numbers, omitting undefined and empty-string
  fields. The Shopify checkout reads the same cookie via
  `Domain=.mystore.com` scoping and honors it.
- Dispatch `visitorConsentCollected` on `window` so listeners (Google
  Consent Mode v2 updaters, custom analytics wiring, etc.) see the
  same payload shape they would from the SDK.

When Shopify ships the SDK fix upstream:

  1. Flip `withPrivacyBanner: false` back to `true` in
     `app/.server/root.ts`.
  2. Delete `app/components/root/consent-banner.tsx`.
  3. Remove the import + mount line in `app/root.tsx`.

No behavioural change for storefronts where the checkout lives on the
same domain (no dot in `storefrontRootDomain`) — those setups never
hit the bug, but they will still render our banner correctly.
@paul-phan
Copy link
Copy Markdown
Member Author

Filed upstream: Shopify/hydrogen#3761 — same root cause analysis, with reproduction steps and code evidence from both broken SDK paths. References this PR as the reference workaround.

When Shopify ships the CDN-side fix (or merges a docs/code change on their end), the 3-step revert procedure in this PR's description stays valid.

@paul-phan paul-phan requested a review from hta218 May 16, 2026 06:29
@hta218 hta218 merged commit b9c48c9 into main May 18, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants