OneTrust records the user’s consent decision and persists it in the OptanonConsent cookie, but your downstream SDKs, pixels, and injected tags never receive that decision — so they either never fire on a fresh accept or keep firing against a stale, denied snapshot captured at page load.

The banner works. The cookie updates. Yet Google Analytics, a session-replay tool, or your own tag loader behaves as if consent never changed. This is not a OneTrust bug; it is a missing subscription. OneTrust broadcasts consent changes through a specific callback contract, and if nothing in your code subscribes to it, the decision stays trapped inside OneTrust.

Triage: Confirm the Decision Never Reaches Your Loader

Work these steps in order. Each isolates one link in the chain from banner click to tag fire.

If the active groups update but your tags do not, the root cause below applies.

OneTrust exposes three separate entry points, and only one of them is a subscription. Reading the wrong one is the whole defect: two of the three are snapshots taken at a moment when the answer is not yet known.

Three entry points, one subscription A timeline of the OneTrust lifecycle. The script loads, then OptanonWrapper runs once at initialisation before the visitor has answered anything. A loader that reads OnetrustActiveGroups at this point sees only the strictly necessary group. The banner is then answered by the visitor, and OnConsentChanged fires with the resolved groups. Only code subscribed to that event observes the real decision; the two earlier read points are snapshots of an unresolved state. otSDKStub loads OptanonWrapper() runs once, before any answer OnetrustActiveGroups reads "C0001" only visitor answers OnConsentChanged fires on every change resolved groups the only trustworthy read A loader wired to the wrapper sees a pre-decision snapshot and concludes the visitor refused everything. Returning visitors muddy this further: their stored choice resolves so fast the race sometimes passes.

The intermittency is what makes this expensive to diagnose. For a returning visitor with a stored choice, OneTrust can resolve before your loader runs, so the same code path succeeds — which is why the bug typically reaches production having “worked when I tested it”. Always reproduce with a cleared consent cookie.

OneTrust does not push consent into your tag loader. It exposes the decision through two mechanisms — a global array (OnetrustActiveGroups) that represents the current snapshot, and a callback that fires whenever that snapshot changes. Code that reads OnetrustActiveGroups once during initialization captures the state as it was before the user interacted with the banner, and never learns about the accept that follows a few hundred milliseconds later.

This is the same architectural gap described in the CMP selection and integration guide: the CMP must be the single authoritative source, and every downstream consumer must react to its change events rather than sampling its state at one arbitrary moment. When you run more than one vendor off OneTrust’s output, the fan-out belongs in a dedicated broker — see syncing consent states across multiple vendors — but the primitive is the same: subscribe, then re-apply.

OneTrust offers two subscription surfaces:

  • OptanonWrapper() — a global function OneTrust calls automatically after the banner script initializes and after every consent change. If you define window.OptanonWrapper, OneTrust invokes it for you. It is the classic hook and runs on both initial load and subsequent changes.
  • OneTrust.OnConsentChanged(callback) — a modern event subscription that fires only when the user actively changes their consent (accept, reject, or a preference-center save). It does not fire on initial page load, which makes it the correct place for logic that should run in response to a deliberate user action rather than the load-time replay.

You almost always need both: OptanonWrapper to apply the persisted decision on every load, and OnConsentChanged to react to live changes without a reload.

Resolution: Subscribe, Read Active Groups, Gate Injection

The fix defines a single mapping from OneTrust category groups to your injection gates, then applies it from both subscription surfaces. OneTrust’s default category taxonomy is stable: C0001 strictly necessary, C0002 performance/analytics, C0003 functional, C0004 targeting/advertising, C0005 social media.

// consent-sync-onetrust.js — load this AFTER the OneTrust autoBlock/otSDKStub
// script tag, or inline it below that tag in <head>.

// 1. Map OneTrust category groups to the gates your loaders check.
//    Verify these IDs against your own OneTrust template — custom
//    categories can shift the numbering.
const CATEGORY_GATES = {
  analytics:   'C0002', // Performance cookies
  functional:  'C0003', // Functionality cookies
  advertising: 'C0004', // Targeting cookies
  social:      'C0005', // Social media cookies
};

// 2. Read the current snapshot. OneTrust exposes the active groups as a
//    comma-wrapped string on window, e.g. ",C0001,C0002,C0004,".
//    OptanonActiveGroups is the legacy alias for OnetrustActiveGroups.
function getActiveGroups() {
  const raw = window.OnetrustActiveGroups || window.OptanonActiveGroups || '';
  // Wrap in commas so ",C0002," matches exactly and C0002 never
  // false-matches inside a longer id.
  return `,${raw.replace(/^,|,$/g, '')},`;
}

function isGranted(categoryId) {
  return getActiveGroups().includes(`,${categoryId},`);
}

// 3. The single apply function. Idempotent: safe to call on every event.
const injected = new Set();
function applyConsent() {
  if (isGranted(CATEGORY_GATES.analytics) && !injected.has('analytics')) {
    injected.add('analytics');
    injectScript('https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX'); // your GA4 id
  }
  if (isGranted(CATEGORY_GATES.advertising) && !injected.has('advertising')) {
    injected.add('advertising');
    injectScript('https://connect.facebook.net/en_US/fbevents.js');
  }
  // Add further categories as needed. Never inject a script whose gate is denied.
}

function injectScript(src) {
  const s = document.createElement('script');
  s.src = src;
  s.async = true;
  document.head.appendChild(s);
}

// 4a. OptanonWrapper runs on initial load AND on every change. OneTrust
//     calls this automatically once it is defined on window.
window.OptanonWrapper = function () {
  applyConsent();
};

// 4b. OnConsentChanged fires only on a live, user-initiated change.
//     It is not guaranteed to exist until the OneTrust SDK is ready, so
//     guard the registration.
document.addEventListener('DOMContentLoaded', function () {
  if (window.OneTrust && typeof window.OneTrust.OnConsentChanged === 'function') {
    window.OneTrust.OnConsentChanged(function () {
      applyConsent();
    });
  }
});

Two details make this correct rather than merely plausible. First, applyConsent() is idempotent — the injected set guarantees a script is added at most once even though OptanonWrapper and OnConsentChanged can both fire for the same event. Second, the gate is a positive check: a script is injected only when its category id is present in the active groups, so a denied or not-yet-decided category never leaks a tag onto the page.

If a category is revoked after a script has already loaded, injection alone cannot claw the SDK back; you need an explicit teardown path. That live-revocation case is covered in handling consent revocation without a page reload.

Mapping groups to vendors, once

OneTrust identifies categories by opaque group IDs rather than names, and those IDs are the contract between the platform’s configuration and your code. Keeping the mapping in one module — rather than scattering C0002 checks through the codebase — is what makes a later category change a one-line edit instead of an audit.

Group IDs are a contract — keep them in one place Four OneTrust group identifiers on the left — C0001 strictly necessary, C0002 performance, C0003 functional and C0004 targeting — pass through a single registry module in the middle, which maps each to an internal category name and the list of vendors that category gates. On the right, the application reads only the internal category names. A note records that scattering raw group identifiers through call sites turns a configuration change into a code audit. OneTrust config one registry module your app reads C0001 C0002 C0003 C0004 C0001 → necessary → [] C0002 → analytics → [ga4, rum] C0003 → functional → [chat] C0004 → marketing → [ads, pixel] the single place a group ID appears has('analytics') names, never IDs A category renamed in the OneTrust console should never require a code search.

Generate your CSP allowlist from the same registry. The vendor list per category is exactly the set of origins that may be contacted once that category is granted, so deriving both from one source removes the class of bug where a tag is consented but blocked, or allowlisted but never gated.

Verification

Confirm both the state and a real tag fire, in one pass:

A single fired analytics request that appears after accept, plus an active-groups string that changed from without-C0002 to with-C0002, confirms the sync is wired end to end.

The third pitfall below deserves a picture, because it is the one that survives review: both callbacks look like “the consent callback”, and wiring everything into either one alone leaves a whole class of visitor unserved.

Two callbacks, two disjoint jobs OptanonWrapper handles the load-time apply and serves returning visitors whose choice is already stored; omitting it means a returning visitor gets no tags at all. OnConsentChanged handles live changes and serves visitors who answer or revise during the session; omitting it means a change never reaches the loader. The two sets do not overlap, so both must be wired. OptanonWrapper() runs once, at initialisation serves: returning visitors with a stored choice omit it → returning visitors get no tags and nothing errors, so nobody notices OnConsentChanged fires on every change, never on first load serves: visitors answering or revising in-session omit it → revocation never reaches the loader a compliance failure, not a cosmetic one The two visitor sets do not overlap, so neither callback is a superset of the other. Point both at the same apply function and the distinction stops mattering.

Common Pitfalls

  • Reading OnetrustActiveGroups once at load. The array reflects state at read time only. Sampling it during your loader’s init() captures the pre-consent snapshot forever. Read it inside OptanonWrapper/OnConsentChanged, never once at module top level.
  • Substring matching a bare category id. Checking groups.includes('C0002') without comma wrapping can false-match if OneTrust ever emits ids that share a prefix. Always match the comma-delimited token ,C0002,.
  • Expecting OnConsentChanged to fire on first load. It does not — it is change-only. If your load-time replay lives solely inside OnConsentChanged, a returning visitor with a saved decision gets no tags. Put the load-time apply in OptanonWrapper and keep OnConsentChanged for live changes.

Related

Up: Selecting and Integrating a Consent Management Platform