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.
Root Cause: No Subscription to OneTrust’s Consent-Changed Signal
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 definewindow.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.
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.
Common Pitfalls
- Reading
OnetrustActiveGroupsonce at load. The array reflects state at read time only. Sampling it during your loader’sinit()captures the pre-consent snapshot forever. Read it insideOptanonWrapper/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
OnConsentChangedto fire on first load. It does not — it is change-only. If your load-time replay lives solely insideOnConsentChanged, a returning visitor with a saved decision gets no tags. Put the load-time apply inOptanonWrapperand keepOnConsentChangedfor live changes.
Related