Skip to main content

Concepts

Consent state reference

This reference covers the edge cases behind the model in how consent works. You need it when you build custom consent UI, run c15t across subdomains, or debug a choice that changes between tabs.

Gate IAB vendors on the TC string

Under an IAB policy, a script, network rule or iframe that names a vendorId or IAB purposes is an IAB target. It runs only while a confirmed TC string grants what it declares: purpose and vendor consent for iabPurposes, purpose and vendor legitimate interest for iabLegIntPurposes, and opt-ins for iabSpecialFeatures. Every category it names must also be free of restrictions, so GPC or strict scope blocks it.

A refused category is the one exception. It does not block an IAB target that processes only on legitimate interest, after publisher restrictions, because the TCF lets that processing run without consent. The visitor's control for it is the objection, which the legitimate interest signals record. The category itself stays refused: its effectivePermissions entry is false, and targets that name only the category, or that also declare a consent purpose or special feature, stay blocked.

Categories under an IAB policy

Under an IAB policy the visitor chooses TCF purposes, and c15t derives each category from them when the choice is saved. A category is granted when the visitor consented to every purpose in it that a listed vendor processes on consent:

CategoryTCF purposes
marketing2, 3, 4
measurement7, 8, 9
experience5, 6
functionality10, 11

necessary is always granted. Purpose 1, device storage, decides no category.

Accept All consents only to the purposes and special features that the vendors in your list declare, because the TCF Policies allow no consent signal for a purpose the visitor was not shown. A category whose purposes no listed vendor processes on consent therefore stays denied after Accept All, and so does every script, iframe or network rule gated on it. With a vendor list whose vendors declare only purposes 1 to 4 and 7, for example, experience and functionality stay denied.

If your own scripts depend on such a category, do one of these:

  • Declare the processing. Add a custom vendor for it, with the purposes it uses, through the customVendors option of your IAB setup. The preference centre then lists those purposes, and Accept All and the purpose switches can grant the category.
  • Gate the script on a category that your listed vendors' purposes cover, or on necessary if it needs no consent.

Check effectivePermissions after Accept All on a fresh visit to see which categories your vendor list leaves denied.

When a choice is saved

A save records the choice in the browser first and sends it to the backend afterwards. The stock banner, preference dialog and IAB surfaces in every framework adapter, and the acceptAll(), rejectAll() and save() methods of the browser and Astro clients, close without waiting for the backend. In order:

  1. In the click task, the explicit choice and effective permissions change, onChoiceRecorded and onPermissionsChanged run, gated scripts, iframes and network rules follow the new permissions, and the surface leaves the active state. Its exit animation still plays.
  2. In the next task, the choice is written to the cookie and localStorage.
  3. After that write is queued, the request to the backend starts.
  4. When the request settles, the kernel emits command:save:completed. The promise returned by kernel.commands.save(), React's performAction() and Svelte's saveConsents() resolves or rejects only then.
  5. If the save turned off a category or vendor that was granted, the page reloads in the next task, after onBeforeConsentRevocationReload runs. Removing a script cannot stop code that already ran, so the reload starts a page with only permitted code. With several saves in flight, it waits for the last one. Set reloadOnConsentRevoked: false to handle revocation yourself.

A failed request does not reopen the surface or roll the choice back. The kernel emits command:error, which reaches the onError callback in adapters that accept one, and queues the payload in localStorage. Where localStorage is unavailable, the queue lives in memory until the page unloads. The queue is replayed after the next successful initialization and when the browser comes back online, up to 10 attempts over 7 days. A replay carries the original action time and policy snapshot token, so the backend records when the visitor decided, and a duplicate submission resolves to the same consent record. A backend that signs policy snapshot tokens rejects a replay made after the token expires, which is 30 minutes by default for the self-hosted backend. A save still queued by then is recorded only in the browser.

A replay that fails and stays queued emits save:replayed with ok: false but not command:error, since the save already reported its first failure. A save that leaves the queue without the backend recording it, because it ran out of attempts, waited more than 7 days or was refused, emits command:error once. Development builds also log a [c15t] console warning for each of these.

IAB surfaces close in the click task too, but an IAB choice is recorded only after its TC string is encoded, which can wait for the TCF library to load. If that local step records nothing, for example because the vendor list failed to load, the surface comes back so the visitor can try again.

Every adapter leaves the same surface after a save: the banner while the policy still owes a choice or a notice, and nothing once no prompt is owed, while the policy is still loading, or after it failed to resolve. A banner the visitor reopened closes too. A save that records nothing new, such as an unchanged selection, closes the surface when it resolves successfully. Opening or closing a surface, or starting another save, before then leaves the surface as the visitor set it.

Closing the dialog without saving, with Escape or the adapter's close call, leaves the same surface a save would. A visitor who opens preferences from the banner and backs out still owes a choice, so the banner comes back. Once a choice is recorded, closing the dialog leaves nothing open.

Preserve records during hydration

Server helpers return records with policy information and evaluation time. Forward that configuration intact. Copying an allowed category into a receipt would invent a grant and lose its original confirmation time.

Valid v2 records can be read without a startup rewrite. The next explicit action writes the v3 format. The upgrade guides for Next.js, React and JavaScript cover how v3 treats v2 records.

Keep open tabs in step

Tabs and windows on the same origin (scheme, host and port) share the consent cookie and localStorage. When a visitor rejects in one tab, every other open tab on that origin applies the rejection without a reload. Scripts and features gated on effectivePermissions lose permission, subscribers are notified once and onPermissionsChanged fires. Clearing records in one tab returns the others to the active policy's defaults: an opt-in policy denies optional categories and shows the prompt again.

Tabs on another subdomain that shares the consent cookie do not share localStorage, so the browser sends them no storage event. They pick up the change on their next focus or visibility change, or when you reconcile yourself as described in Reconcile yourself or turn it off.

The storage event only exists for localStorage. When localStorage is unavailable (blocked by the browser, a sandboxed frame or a privacy mode) and c15t stores in the cookie alone, another tab's change arrives only on the next focus or visibility change, or when you call runtime.reconcileStorage() or persistence.reconcile(). c15t does not poll the cookie or use a BroadcastChannel for this.

Browser persistence reads stored records again at these moments:

MomentWhat happens
Another tab or window on the same origin changes a c15t localStorage keyReconciles on the storage event
The page becomes visible againReconciles on visibilitychange
The window regains focusReconciles on focus
The network reconnectsNo storage read

Several triggers in quick succession run one reconciliation in a later task. Reconnecting does not read storage, because going online changes nothing in browser storage. On reconnect the kernel retries saves the backend did not accept and a failed initialization. Save retries do not write the cookie or localStorage. A write that follows initialization obeys the rules in Ordering with pending writes.

Every adapter that mounts browser persistence does this: the React, Next.js and TanStack Start providers, Vue, Svelte, Astro, the script tag and createConsentRuntime. With persistence: false nothing is stored or read.

What a reconciliation applies

Stored records are merged into the ones in memory:

  • Category decisions merge per category. Each category keeps the decision with the newer confirmation time, so a tab that only changed marketing never reverts another tab's newer measurement decision.
  • The notice dismissal and the vendor record are single decisions. A stored one at least as new as the one in memory replaces it; an older one is ignored.
  • When two tabs record decisions in the same millisecond, the one stored first wins in both tabs.
  • Tabs keep one subject. A stored subject is considered only when the record that carries it changed, never on focus alone. It replaces a subject the tab generated on its first save or copied from storage, so a tab that opened before another tab stored a subject joins it. A subject id the server resolved (at init, from a prefetch or in a save response) is replaced only by a strictly newer stored choice, and an identity set with identify() is never replaced.
  • Under an IAB policy, the TC string follows the reconciled choice. See How the TC string is reconciled.
  • A record removed from readable storage since this tab last saw it present is cleared, and the active policy decides again. A record this tab never saw in storage, such as a receipt merged from the server after identify() or a choice seeded while storage was blocked, stays.
  • Blocked storage, or bytes that do not decode, change nothing. A failed read never grants a category.

Expiry is not decided here. The applied records are evaluated at the time of the read, the same way as at startup.

How the TC string is reconciled

Under an IAB policy, a tab adopts the TC string another tab stored, unless that TC string conflicts with the reconciled choice. A conflicting TC string is withdrawn, and __tcfapi reports no consent until the next save. Vendors never receive a stale TC string: c15t withdraws it, or holds it back, before __tcfapi publishes.

  1. The @c15t/iab module loads the TC string the other tab stored, with its purpose, vendor and special-feature selections, so __tcfapi and the preference controls show the choice now in force. Selections the visitor changed in this tab without saving are kept.
  2. A missing or older stored TC string leaves the current one in place, unless the current one conflicts with the reconciled choice. Then it is withdrawn.
  3. A category denied after the TC string was saved conflicts if the TC string grants any of its purposes. A partial selection saved through IAB, which records its category as denied because not every purpose is granted, keeps its TC string.
  4. A TC string confirmed before the choice's newest decision no longer describes it and is withdrawn, unless a newer receipt replaces it. That receipt is adopted even when its TC string is identical, because TC strings round their time to the day and custom-vendor selections live only in the receipt. The receipt's expiry then applies.
  5. The TC string and its receipt (euconsent-v2, c15t-iab-authority-v1) belong to one origin. After a save on a sibling subdomain that shares the consent cookie, this subdomain reports no consent through __tcfapi until it saves again.
  6. A tab reloads the TC string when another tab on the same origin stores a new one. This covers a save in the same millisecond, or one that changed only vendors.
  7. When two tabs save in the same millisecond with different selections, the more restrictive TC string wins in both, so a revoked vendor is never advertised again. If each grants something the other denies, neither is published until the next save, and the stored receipt is removed so a page opened later does not restore it.
  8. When another tab removes the receipt or clears localStorage, this tab withdraws its TC string too, because a page opened now would find none. A receipt this tab was still decoding is not installed.
  9. The removal after a tie checks that the stored receipt is the one it read. localStorage has no conditional removal, so a receipt another tab stored a moment earlier can still be removed. Every tab then withdraws its TC string until the next save, so the race only ever withholds consent.

Clearing records across tabs

clearRecords() also drops every save queued for replay, with or without browser persistence, so nothing the visitor decided before the clear reaches the backend afterwards.

clearRecords() removes every record and then stores the time of the clear, the clear epoch, under its own key: c15t-epoch in localStorage and a cookie of the same name (<storageKey>-epoch with a custom storageKey). Clearing never removes it. Every consent record written afterwards also records the epoch it was written under.

A decision confirmed before the epoch was made before the clear, so it is void wherever it turns up. A decision stamped in the very millisecond of the clear counts only if it comes from a tab that had already seen that clear, so a tab's own choice right after its own clear stands while another tab's decision in the same millisecond does not. A stored record left with no decisions after the clear is void too, subject included.

  • A tab that reconciles only after another tab cleared and saved again drops its pre-clear decisions instead of merging them back. Its subject from before the clear is dropped too.
  • A tab that missed the clear writes only decisions it made after it. Its queued write of an earlier decision is discarded, so it cannot bring back a cleared record.
  • Browser hydration and server reads (readStoredRecordsFromCookieHeader, used by the Next.js, TanStack Start, Nuxt, SvelteKit and Astro helpers) apply the same rule, so a server render agrees with the browser.

Records from before any clear, including v2 and legacy records, read as epoch 0 and are unaffected. A corrupt epoch, or one that cannot be read, also reads as 0: it voids nothing, so a failed read never grants a category. A consent record whose own epoch field is corrupt is kept, and only its epoch is ignored.

Each clear moves the epoch forward, even when the device clock went back, so a later clear never lets earlier decisions back in. Two clears in the same millisecond therefore leave the epoch a millisecond ahead, and a decision saved in that millisecond is void. An epoch up to one hour ahead of the clock is kept, as it is when the clock was set back after a clear. Until the clock catches up, decisions saved in that window are void too. An epoch more than an hour ahead is treated as corrupt and reads as 0, so a clear never writes one: after the clock went back more than an hour, the new epoch is capped at an hour ahead of the clock.

That cap is a known limit. A cleared record carries times from before the clock went back, and a runtime that missed the clear can write those times back. The capped epoch is lower than them, so once the clock has recovered, such a decision counts again. Leaving the epoch uncapped does not help: every tab whose clock is still behind reads it as corrupt, which voids nothing. Times alone cannot order a clear against decisions stamped by a clock that went back more than an hour.

This changes the stored format. After a clear, the consent cookie carries &e=<time> (16 bytes) and the localStorage record an epoch field (22 bytes), and the epoch cookie itself holds a 13-digit time. Visitors who never cleared their records store exactly what they did before. An older c15t build rejects both the cookie and the localStorage record once they carry the epoch, so a page still running one treats the visitor as undecided. Under an opt-out policy that page grants optional categories by default until a new choice is saved, and the configured prompt may appear again. Deploy the new build to every page of the site before visitors can clear their records.

When both copies hold a decision for a category from the same millisecond and the two conflict, the denial wins.

The subject and IAB metadata come from the cookie. A server response, such as server-side consent restoration, and a sibling subdomain sharing the cookie with crossSubdomain can both rewrite it without touching this origin's localStorage, so the local copy can be the older one. The local copy's subject is used only when this browser's last consent write reached localStorage but not the cookie, for example because the cookie grew past the size limit, and the cookie has not changed since. Such a write stores the cookie as it stood under <storageKey>-cookie-miss in localStorage; the next write that reaches the cookie, or a clear, removes it. When localStorage rejects a write that the cookie takes, for example because storage is full, the older localStorage copy is removed.

When a server render seeded the page from the consent cookie (skipHydration), that seed stays authoritative. A denial that reached only localStorage is still applied on top of it when the page mounts, since it can only restrict, if it is newer than the seeded decision or from the same millisecond as a seeded grant. A stored grant is not.

The notice dismissal and vendor denials are stored twice as well. A vendor list in localStorage at least as new as the cookie's adds its denials but never lifts one the cookie holds; a copy confirmed before the last clear is ignored, so its denials never come back. The newer notice dismissal applies; it still only hides a notice with the fingerprint it names. The consent record follows the rules below.

The consent record is stored twice: as a cookie, which a server render reads, and in localStorage. The cookie is authoritative. A well-formed cookie wins even when it has expired, so a local copy can never bring back a grant the cookie no longer carries.

A browser can still drop a cookie write, for example when the record grows past the cookie size limit, while localStorage takes it. A denial in the local copy that is newer than the cookie's decision for that category is therefore applied on top of the cookie. A newer local grant is not, so a dropped cookie write only ever leaves the visitor with less permission. A server render sees only the cookie; after a dropped write that carried a denial, the browser is the stricter of the two.

When the two copies were written under different clear epochs, each loses its decisions from before the later epoch first, and the same rule applies to what remains. A later epoch in the local copy never lets its grant replace a cookie denial. The subject and IAB metadata come from the copy written under the later epoch; a record written before the clear in force keeps its later decisions but no subject.

A server render cannot know about a clear that never reached a cookie. If the page could write localStorage but its cookie writes failed during clearRecords() (cookies blocked for the page, or a cookie setter that throws), the removal of the consent cookie failed too, and so did the epoch cookie. The browser then applies the clear from localStorage, while a server render still reads the old consent cookie until the next successful cookie write. The same applies to a consent cookie set with a different domain than the current storageConfig uses, which the clear cannot remove.

Ordering with pending writes

A tab writes its own choices in a later task, not during the click. Before it reads storage, it lands its own queued writes, so a reconciliation never undoes the visitor's latest action in that tab. A queued write follows the same rules as a read: it stores the per-category merge of its choice and the stored one, and never replaces a newer notice or vendor record. It keeps the stored subject unless this tab identified a different user. When a slow save response returns a server subject id, the tab adds it only to the record it wrote; it does not recreate records another tab cleared or overwrite another tab's newer choice.

Two tabs that write at the same moment can both read storage before either writes, and the later write can then drop the other tab's category decision. The tab whose decision was dropped still holds it, and on its next reconciliation it writes it back, merged with what storage holds. It does so only for decisions it actually stored itself, never for one a clear voided or another tab replaced with a newer one.

If another tab's change reaches this tab while one of its saves is pending, the save depends on how far it got. A save not yet sent is dropped. A request already sent still reaches the backend, which may record it; this tab ignores the response, so it queues no retry and applies no subject id from it. A save already queued for retry keeps its original decision time.

Reconcile yourself or turn it off

Browsers send no event for a change made in the same document, or for a cookie rewritten without a localStorage change, such as a Set-Cookie response header or another subdomain sharing the cookie. The next focus or visibility change picks it up. To apply it at once, call the method yourself:

// Runtime owners: returns true when any record changed.
runtime.reconcileStorage();

// Kernel owners with createPersistence from c15t/modules/persistence:
persistence.reconcile();

To keep storage but stop automatic reconciliation, pass sync: false in the persistence options, for example createConsentRuntime({ persistence: { sync: false } }) or createPersistence({ kernel, sync: false }). The manual methods still work.

dispose() removes the listeners and cancels a scheduled reconciliation.