TCF Consent String
What a first-party platform has to get right when it reads an IAB TCF TC String: three version counters that are not the same number, purpose bitfields indexed from zero while purposes are numbered from one, a vendor list that must be pinned to the version the string names rather than to the newest one, an event-driven read path on web and in app and a consent log that keeps the string itself.
- Your platform reads TC Strings and never writes one A string your own code synthesized is not a framework signal, so every decision taken on it has to be found and taken again once a registered CMP writes a real one.
- Name which version counter you mean, every time Collapse the encoding format version, the policy generation and the vendor list document's specification version into one TCF version and your validity checks run against the wrong number.
- Evaluate a string against the vendor list version it names Pin and keep every archived version, because a string resolved against today's list points at vendors and declarations that were never the ones on screen.
- Branch on the event status, not on the presence of a string A read taken while the interface is on screen files a complete refusal nobody gave, and every decision built on it has to be re-run afterwards.
- The empty string in app storage is your own default value Waiting longer fixes nothing, so read through a change listener and hold the user as undetermined until a key appears, or an uninitialized CMP will look like a refusal.
A TCF consent string is the artifact a consent management platform hands your code, and it governs what your product may do with a European user's data under the framework. The IAB Tech Lab document defining the format states the purpose in its TC String and vendor list formats specification: "A TC String’s primary purpose is to encapsulate and encode all the information disclosed to a user and the expression of their preferences for their personal data processing under the GDPR." What arrives is a compact run of URL-safe base64: bitfields indexed from zero while the things they describe are numbered from one, version counters that are easy to collapse into a single number. On the in-app path there is nothing yet, an empty string until a CMP has written anything.
In short: a platform that is not a registered CMP reads this artifact and never writes it. Three counters are easy to collapse into one number, which is the first thing to get right. Evaluate a string against the archived vendor list version it names. Branch on the API event status instead of on the presence of a string, because the string on screen is a placeholder. The field table carries the rest.
What a TC String is and who may write one
Two sentences settle the shape of every first-party integration, and both sit in the format specification. The first says why anyone decodes one: "Vendors decode a TC String to determine whether they have the necessary legal bases to process a user's personal data for their purposes." The second is a prohibition with no exception attached: "Vendors or any other third-party service providers must neither create nor alter TC Strings."
IAB Europe's TCF Implementation Guidelines state the same rule from the writing side: "Only CMPs can write the Transparency and Consent string, where consent is stored." A team that synthesizes its own string because the CMP is slow has left the framework rather than implemented it.
Two organizations stand behind the framework and they are not interchangeable. IAB Tech Lab stewards the technical specifications in the public repositories. IAB Europe is the managing organization: it governs the policies, registers CMPs and publishes the Global Vendor List. Its own framework page calls the TCF an accountability tool rather than a compliance outcome. No correctly parsed string discharges a controller's other obligations.
The artifact is one mandatory core segment carrying the version fields, the purpose bitfields and the vendor sections, followed by optional segments for disclosed vendors and for the publisher's own purposes. Those are position independent, each carrying its own segment identifier, so a parser that dispatches on position breaks on the first string missing one.
The three version counters that are not the same number
Three counters are easy to collapse into one. The encoding format version sits in the core segment and separates a version-one string from a version-two one. Policy version is the generation of the TCF policies the string adheres to. Third is the specification version of the vendor list document, which describes the shape of that JSON rather than anything inside the string. A fourth number, the vendor list version, identifies which published Global Vendor List was on screen when the user chose, and it is the number the string pins.
The Implementation Guidelines tie two of them together: "The global vendor list (spec version 3) has the policy version encoded accordingly to 4 or higher." They also record a renumbering that breaks naive comparisons: "We will restart the vendor list version at 1 and increment for each update." A vendor list version from the older generation can be numerically larger than a current one while being years older, so recency comes from the list's own last-updated field rather than from a number that restarted.
Two counters carry hard validity rules. The format specification states one as a floor with a date attached: "Post 30 September 2023 a TC String created with a policy version set to smaller than 4 will be deemed invalid." The other is a comparison a CMP makes before it relies on a stored string: "If the policy version number in the latest GVL is different from the value in your TC String, then you need to re-establish transparency and consent for that user." A string below the floor is discarded rather than repaired.
The rule around version 2.3 has named parties. IAB Europe states on its framework page that "TCF participants have until 28 February 2026 to adopt TCF v2.3 and make the necessary changes to their respective implementations."
Google documents the same cutover from the consuming side on its publisher integration page: "TCF v2.3 is mandatory for all TC strings generated on or after March 1, 2026." The two dates are the closing and the opening side of one boundary.
Version 2.3 is not the end of the line: the changelog inside the format specification records revisions past it, so v2.3 carries the adoption rule rather than the newest format.
Every field and what breaks when it is misread
Each row of this table stands on its own. The meaning column is sourced from the field tables of the specifications cited here, rewritten as our own prose. The column for what a platform does is Pharos Production practice rather than a requirement of the standard. Every failure in the last column is one the cited specifications state or directly imply.
| Segment or field | Meaning (sourced) | What the platform does (Pharos Production practice) | Failure the sources describe |
|---|---|---|---|
Created and LastUpdated |
Timestamps whose resolution was changed from deciseconds | Use as the age input for the expiry policy, and record the resolution assumption in the decoder | Reading a decisecond value as seconds puts the consent moment in the wrong decade |
CmpId and CmpVersion |
The registered CMP that wrote the string, and its own version | Validate the identifier against the Global CMP List before trusting anything else | An unregistered or removed CMP identifier means the string is not a TCF signal at all |
VendorListVersion |
Which vendor list version was on screen when the user chose | Pin the archived list of exactly that version and evaluate against it | Evaluating an old string against today's list resolves its positions against vendors and declarations that were never the ones on screen |
TcfPolicyVersion |
The policy generation the string adheres to | Compare against the policy version in the current vendor list on every read | A string below policy version 4 has been invalid since 30 September 2023 and must be discarded |
IsServiceSpecific |
Must always carry the value 1; a zero marks the string invalid | Treat a zero as a hard reject with no repair path | Accepting a global-scope legacy string, invalid since 1 September 2021 |
PurposesConsent and PurposesLITransparency |
Per-purpose consent and legitimate-interest transparency, purpose 1 at bit index 0 | Map your own processing to purpose identifiers, then read both fields at identifier minus one | Off-by-one across the bitfield, or reading one field and concluding a refusal the other contradicts |
| Publisher restrictions section | Entries of purpose identifier plus a two-bit restriction type, where type zero forbids the purpose outright | Apply the restriction before evaluating the vendor's own declared basis | Applying legitimate interest where a publisher restriction requires consent on a flexibly registered purpose |
| Disclosed vendors segment | Mandatory since v2.3; lists the vendors actually presented to the user | Require its presence on any string created on or after 1 March 2026 | Absent segment on a post-cutover string, where before the cutover a legitimate-interest bit stood in for it |
| Vendor list endpoint generation | More than one generation of the list endpoint is served, and only one of them is still being updated | Pin the current generation and alert when its last-updated field falls behind the weekly cadence | A pinned older endpoint answers with HTTP 200 forever and the platform never learns its list is frozen |
In-app IABTCF_TCString |
The full encoded string in the app's own preference store | Read through a change listener rather than once at launch, and treat an absent key as undetermined | The documented read supplies an empty string as the caller's default, which is then mistaken for a decision |
Decoding and validating a TC String
Decoding starts with the transport. The string is URL-safe base64, so a decoder built on the standard alphabet either rejects the string outright or, if it is lenient, drops the two substituted characters and produces plausible nonsense instead of an error. Then comes bit-offset parsing of the core segment in its declared field order, then a dispatch on the segment identifier of whatever follows.
The bitfields are where first-party decoders go wrong, and the format specification puts the rule in one sentence: "From left to right, Purpose 1 maps to the 0th bit, and purpose 24 maps to the bit at index 23." A loop that treats the index as the identifier gives every purpose its neighbor's answer while the set still looks valid. Timestamps carry a trap of the same family. The specification records that "The Created and LastUpdated fields previously corresponded to decisecond timestamps." Nothing complains when a consent moment lands in the wrong decade.
Validation is a checklist rather than a parse. The Implementation Guidelines name the four signals that carry the answer: "The relevant signals in the TC string are the GVL version, the publisher restrictions signal, the purpose legal basis signal and the vendor legal basis signal." One negative case must never be read as a positive one: "Given the vendor was not disclosed both vendor consent and vendor legitimate interest signals in the TC String can be left undefined which suffices to signal that the vendor may not process personal data."
Two rejects are unconditional. Global scope is gone, as the specification records: "Since Sept 1st 2021, TC strings established with global-scope are considered invalid." The second reject is identity: a Global CMP List exists so a receiver can check whether a string's CMP identifier is registered and active.
A consuming platform's definition of failure is worth borrowing. Google's publisher integration page, per its own help documentation, treats a TC string as invalid when it is not parseable, for instance when fields are missing. In Pharos Production practice the decoder returns one three-valued answer per purpose and per vendor. An unparseable string maps to no basis rather than a retryable error, and the reason is recorded beside the decision. Expiry is the last input, and its period is a policy the publisher and its CMP set.
A misread string costs more than a rerun of the parser, so our practice treats one as an incident rather than a bug. Every decision the platform took while the misreading was live has to be found and taken again, so the consent log has to be queryable by string and by time. Derived data built on those decisions gets quarantined or deleted, because it inherits the same defect. Where the corrected reading leaves a user undetermined, that cohort goes back to the CMP to be asked again.
The Global Vendor List and version pinning
The vendor list is the dictionary without which the bitfields mean nothing: vendor identifiers, purposes and declared legal bases live there, and the string carries only positions into it.
Fetching it from a browser stopped being allowed at version 2.0. The format specification gives the reason: "Given the scale of the TCF and the high volume of requests for the Global Vendor List, this is no longer possible from TCF v2.0 onward." Publication is weekly. Archived versions may be cached indefinitely because they are static, and a cached copy must honor the cache-control period at minimum.
Pinning matters more than freshness. That fourth counter is the one a string pins, and its identifiers must resolve against exactly that vendor list version. The specification turns the same comparison into the resurfacing rule: "To determine whether the interface should be resurfaced to a user, the CMP must compare the latest version of the GVL with the archived version of the GVL identified in the TC String (assuming they are different)." It also allows the offline case: "For a delay caused by a lack of connectivity, the last cached version of the GVL may be used but must be updated as soon as connectivity is restored."
One failure here we checked rather than assumed. When the sources for this article were read, an older generation of the list endpoint still answered with a complete payload and an HTTP 200 while its own last-updated field sat in 2023. A platform pinned to that URL never gets an error and never learns its list is frozen, so our practice is to alert on the last-updated field rather than on the fetch failing.
Mapping your own processing to purposes and legal bases
This is the work a library cannot do for you: writing down what your product does with personal data and which numbered purpose covers each activity. Our marketing software development guide covers the consent types a marketing platform handles at product level.
Consent is not one bit. Google puts it plainly on its EEA disclosure page: "Consent is not represented by a single bit, but rather a set of purposes and vendors as defined in the IAB TCF specification". The same shape applies to your own processing: a purpose bit without the matching vendor bit authorizes nothing. Legitimate interest sits beside consent rather than beneath it, so a decoder that reads the consent bitfield and stops produces a refusal wherever the other basis applies.
Flexible legal bases are the reason a vendor's registration is not the last word. The Implementation Guidelines state the mechanism: "When a vendor has declared their legal basis for a purpose as flexible, the publisher can change the vendor’s default choice." A publisher can restrict a flexible legitimate-interest default to require consent instead, so a decoder that resolves the registered default and stops has skipped the publisher entirely.
Publisher restrictions carry that override, and their ceiling runs one way. Google's publisher integration page states the limit: "Publishers can never cause a vendor to operate under a lawful basis or for a purpose which conflicts with the vendor’s Global Vendor List registration." Evaluation order in our practice is the publisher restriction first, then the flexible-purpose rule, then the vendor's declared basis.
One field pair exists purely to stop a false negative. A zero in the purpose 1 consent bit can mean a refusal or it can mean consent was never sought, and the format specification says why the string carries the distinction: "To accommodate cases where Purpose 1 is governed differently for consent depending on the jurisdiction, a TC String is transparent about the publisher’s operating governance and whether or not Purpose 1 was disclosed to a user." Read the publisher country code and the purpose one treatment flag before concluding anything.
At v2.3 the disclosed vendors segment changed meaning, and a reader implements that change rather than observing it. The specification describes the old behavior as a property of old strings: "For TC strings created prior to March 1, 2026, the Legitimate Interest bit was set to 1 for vendors declaring Special Purposes." A vendor in that list now means it was presented to the user, so a reader still inferring that from a legitimate-interest bit is answering a question the string no longer asks.
The web CMP API and its event statuses
On the web the string reaches you through a single global function rather than a storage read. Three commands are the mandatory floor, and the CMP API specification names them: "All CMPs must support three required API commands: 'ping', 'addEventListener' and 'removeEventListener'." The direct data command was deprecated at version 2.2, so a caller registers a listener and reads the data object from its callback.
One misreading costs more than the rest: a read taken at the wrong moment. While the interface is on screen the specification requires a specific placeholder: "The CMP shall create a TC string with all the surfaced vendors’ legitimate interest signals set to true and all the consent signals set to false." A platform reading at that instant files a complete refusal for a user who has not answered. Of the three event statuses, the one marking a completed user action is an answer and the one marking a loaded CMP with an existing valid string is an answer. The one marking a surfaced interface is not.
The status carried alongside the data has its own fail-closed rule. On the error status the specification is blunt: "A CMP shall not respond to any other API requests if this cmpStatus is present." A caller that keeps issuing commands into an errored CMP waits for callbacks that never arrive, so the timeout path ends in no processing rather than a retry loop.
Whether the framework applies at all is an assertion rather than a lookup. The specification states it directly: "The value of gdprApplies represents the perspective of the Publisher." It is not derived from location, and undetermined is not negative, so in our practice undetermined maps to do nothing yet. One deployment constraint remains: the CMP must load in a frame ancestral to every iframe needing a legal basis.
The in-app path and the empty string

In an app there is no shared global function, so the contract is a set of keys in the platform's preference store. It does not live in a separate mobile specification: the only standalone in-app document in the framework repository is the 2018 version-one text, and the version-two in-app rules are a section of the CMP API specification itself.
Storage is named. The CMP API specification states where the values go: "On Android OS, the TC data and TC string shall be stored in the default Shared Preferences for the application context." The iOS equivalent is the standard user defaults store, and on both platforms a vendor is notified when the shared keys change. Exactly one CMP may run, however many are linked: "Since more than one CMP SDK may be included in publishers' linked SDKs, the publisher must initialize only one of them." The one that runs writes its own identifier key on initialization, and the absence of that key is the no-CMP-yet state.
Now the empty string, the symptom that reads like a decoder bug and is not one. The raw string sits under a single key, and the documented Android read supplies an empty string as the caller's own default for a key that is not present. Before a CMP has initialized and written, that key is absent. The empty string is your fallback rather than a value any CMP wrote, and it says nothing about the user. A longer wait is not the fix: read through a change listener and treat an absent key as undetermined.
The remaining keys are pre-parsed, which is why an app rarely needs to ship a decoder at all. Each is a string in which the character at position n carries the status for identifier n plus one, so the off-by-one reappears one layer up. Types are flattened, and the warning has a line of its own in the specification states: "Note: For mobile all booleans are written as Number (integer)." Removal leaves residue, and clearing it is assigned: "If a publisher chooses to remove a CMP SDK from their app they are responsible for clearing all IABTCF_* vestigial values for users so that vendors do not continue to use the TC data therein." A migration that skips that step leaves phantom consent in the store.
Refresh is per launch rather than per session. Google's consent setup documentation states the cadence: "You should request an update of user consent information at every app launch using requestConsentInfoUpdate() to check if consent is required or if a privacy options entry point is needed." A launch does not always produce a prompt, since consent may already exist from a previous session.
Server side propagation and consent logging
Most of a platform's processing happens away from the device that produced the string, so it has to travel. The web-native path is a macro substitution, and the format specification defines it: "Any caller with access to the applicable TC String must insert it within a URL containing the macro ${GDPR_CONSENT_XXXXX} where XXXXX is the numeric Vendor ID of the vendor receiving the TC string." A substitution that silently fails sends the literal macro text downstream, which decodes to nothing and reads as no consent. Everything else is bilateral: agree the payload shape with the partner.
In our practice the internal transport carries the encoded string itself rather than a decoded snapshot, because a snapshot cannot be re-evaluated later against the vendor list version it was made under. The record worth keeping is the string, the moment it was read, the vendor list version it named and the decision your platform took, because a decoded boolean per purpose cannot be replayed. The same discipline serves the request side, where our note on handling wallet data deletion requests covers answering within a deadline.
GPP as the container
The Global Privacy Platform is a wrapper rather than a replacement. Its core string specification describes the header as a table of contents of the sections a payload contains and where each signal sits. Contents are not pinned by the container, since it "delegates regional policy versions and technical encoding versions to each substring section so that each may develop independently of each other and the header design." That document writes the same read-only rule for the container itself: "Vendors or any other third-party service providers must neither create nor alter GPP Strings."
The European section is specified separately, and its own document says what has not changed yet. IAB Europe's TCF extension holds participants to the standalone path until the transition that document describes is complete: "Until then, the TC string must be included in your transaction headers, the TCF API must be implemented on your page, and vendors are required to look for and interpret the TC string for all transactions where GDPR is applicable." So a platform keeps the standalone path and adds detection for the container instead of swapping one for the other.
Detection is cheap because the generic command surface is synchronous: the GPP CMP API specification requires that "All generic commands must always be executed immediately without any asynchronous logic and call the supplied callback function immediately." Our default is to probe on every page and fall back to the standalone API when the container is absent.
How Pharos Production helps
A TCF integration on a first-party platform is four pieces of software and one habit somebody owns, and the habit is the written map from your processing to numbered purposes and legal bases.
Our marketing software development practice builds exactly those pieces: a decoder built against the current format and its validity rules, a vendor list cache that pins archived versions rather than chasing the newest, consent reads that branch on event status instead of on the presence of a string and a consent log that keeps the string itself so any past decision can be replayed.
Sources: the IAB Tech Lab TCF v2 specifications on GitHub (the TC String and vendor list formats document, the CMP API v2 document) and the IAB Europe TCF Implementation Guidelines; the IAB Tech Lab Global Privacy Platform specifications (the core consent string specification, the CMP API specification and the IAB Europe TCF section for the EEA); the IAB Europe Transparency and Consent Framework page; the Global Vendor List endpoints on vendor-list.consensu.org; Google's AdMob and UMP SDK documentation. Read on 17 September 2026. Engineering guidance, not legal advice.
FAQ
Quick answers to common questions about custom software development, pricing, process and technology.
Type to filter questions and answers. Use Topic to narrow the list.
Showing all 6
No matches
Try a different keyword, change the topic or clear filters
-
Adopt one, in almost every case, and the reason is structural rather than economic. Only a registered CMP may write a TC String.
Registration assigns an identifier that receivers validate against a published list, and a string written by an unregistered party is not a framework signal at all. Building one means taking on the interface, the policy texts, the vendor list handling and the registration obligations that go with them. The build decision worth having instead is about the reading side: your own decoder, your own vendor list mirror and your own consent log are all yours to own.
-
When it is not participating in the advertising supply chain the framework exists to coordinate. The TCF standardizes how transparency and consent signals travel between publishers, CMPs and registered vendors.
A product with no third-party vendors in scope does not stop needing a lawful basis, and where a consent mechanism is required it still needs one, but it does not need to encode that as a TC String. Adopting the framework for purely first-party processing adds a vendor list, a registration relationship and a bit-level format, and none of that is what establishes your basis.
-
Mostly the mapping work, and it gets harder rather than easier. Your processing is real and it still needs a basis, but it does not map cleanly onto purposes written for an advertising supply chain, so somebody has to decide which numbered purpose covers each activity and write that decision where a lawyer and an engineer can both read it.
The publisher segment of the string carries publisher purposes and custom purposes for exactly this case. Treat those as separate from the standard vendor purposes, and never read a custom purpose bit as though it were a standard one.
-
With recorded strings rather than a live interface, which is our practice rather than anything the specification prescribes. Capture real strings from your CMP across the cases that matter: a full acceptance, a full refusal, a mixed choice, a legitimate-interest-only vendor, a publisher-restricted purpose and a string from an older vendor list version.
Store each one with the archived vendor list it names, then assert the decision your decoder produces for every purpose and vendor. On the app side, add a case where the key is absent entirely, because that is the state the empty string actually represents.
-
Daily is our default, against a published cadence of weekly. The list changes on a weekly schedule, so a daily fetch is cheap insurance against a missed window rather than a way to be more current than the publisher.
What matters more than the interval is the alert: a fetch that succeeds while the list's own last-updated field stops moving is the failure mode that hides, since the endpoint keeps answering normally. Archived versions are kept indefinitely and never pruned, because any of them may be needed to explain a decision taken while it was current.
-
Treat the string as the record and the CMP's reported state as a claim about it, which is Pharos Production practice rather than anything the specification prescribes. Re-read both from the same moment first, since a disagreement is usually a read taken before the event status settled or a decoded snapshot compared against a string the CMP has since rewritten.
If the two still differ, act on the narrower of the readings, log both with the raw string beside them and raise it with the CMP as an integration defect. Never reconcile the difference by writing a string yourself.
I work with startup founders who need a dedicated software development team but don’t want to gamble on hiring, random outsourcing, or opaque delivery.
Most founders face the same problem sooner or later.
Early technical and team decisions lock the product into tech debt, slow delivery, missed milestones and constant re-hiring. By the time this becomes visible, fixing it is already expensive.As a CTO and software architect, I help founders design, build and run dedicated development teams that work as a true extension of the startup. Not as a black-box vendor.
My focus is on complex products where mistakes are costly:
- Web3 and blockchain platforms
- FinTech and regulated products
- High-load startup systems
- MVP → scale transitions
We don’t do body-shopping.
We don’t sell generic outsourcing.Instead, we help founders:
- build the right team structure from day one
- keep technical ownership and transparency
- scale delivery without losing control
- avoid vendor lock-in and hidden risks
Teams are aligned with the product roadmap, business goals and long-term architecture. Not just short-term velocity.