green decorative image with snippets of code across it

Building a Consent Management Platform on iOS

Our adjoe WAVE team builds an SDK that helps publishers to monetize their apps by displaying ads. 

WAVE prioritizes compliance with essential regulatory and data privacy frameworks, including the IAB Europe Transparency and Consent Framework (TCF). We recently integrated a consent management platform (CMP) solution into our WAVE SDK, which enhances the transparency, experience, and privacy for publishers, advertisers, and end users. 

In this article, I will address the main challenges we encountered while building our own CMP. I’ll cover two topics: the CMP UI and CMP API. Together, these will give you a general overview of what it takes to implement a CMP on iOS.

What Is a Consent Management Platform?

A CMP is software that develops notices to inform users and capture their preferences regarding how their personal data is processed. This transparency allows for a more positive and sustainable experience in the apps they interact with. CMP has become a key part of IAB Europe’s TCF. 

The primary goal of a TCF is to ensure compliance with data protection regulations, such as the General Data Protection Regulation (GDPR) in the European Union, by providing users with transparency and control over their personal data.

The main responsibilities of a CMP are to

  1. Handle user permission requests: a CMP provides a user interface to establish transparency for users and obtain consent or register objections from end users. A CMP presents users with clear and concise information about the types of data the app intends to access or process.
  2. Share user preferences: A CMP provides a standardized means for parties, such as publishers or advertisers, to access these permissions via a CMP API.

Building the CMP UI

Our CMP UI consists of three main parts:

  1. Layer 1: This is the first screen with which the user interacts when the host app asks the SDK to present a consent screen. It gives a user an overview of what data needs to be used and for which reasons. There are two main ways to go from here: The user either agrees to everything or they can dive deeper into the details by tapping “Change choices.”
  2. Layer 2 (ad preferences): Here, users can adjust their preferences more precisely. They can give their consent for specific purposes of data usage and reject it from being used for other purposes.
  3. Layer 2 (partners): This screen gives users an opportunity to adjust their preferences individually per advertising vendor.
screenshots of consent management platform CMP

Becoming TCF-compliant is a rigorous process that ensures every aspect, from UI to CMP implementation, is achieved to the highest standard by data privacy vendors. It has many requirements regarding the CTA button text and styles, font contrast rate, and, mainly, the transparency of the information that you need to disclose to users. This allows the IAB to ensure the highest level of quality for users interacting with the platform and guarantees data security for those who have successfully implemented the measures.

We’ve chosen SwiftUI framework to build our CMP UI and be able to change our UI fast and flexibly. It’s a relatively new UI framework developed by Apple, and its main feature is that it uses declarative syntax.

The main benefits we gained from SwiftUI are as follows:

1. CMP screens are mainly collection views 

With the old UI framework, UIKit, it takes tons of boilerplate code (mainly because of imperative code and delegate pattern) to implement complex collection views that display heterogeneous data.

 With SwiftUI, building a collection view is as simple as iterating over an array and mapping each element to a cell view instance. Here’s a comparison between UIKit and SwiftUI implementation for a simple vendor list:

// UIKit implemenation
class VendorListTableViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
   let vendors = ["Vendor 1", "Vendor 2", "Vendor 3"]
   let tableView = UITableView()
   override func viewDidLoad() {
       tableView.dataSource = self
       tableView.delegate = self
       tableView.register(UITableViewCell.self, forCellReuseIdentifier: "VendorCell")
       tableView.translatesAutoresizingMaskIntoConstraints = false
           tableView.topAnchor.constraint(equalTo: view.topAnchor),
           tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
           tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
           tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
   func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
       return vendors.count
   func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
       let cell = tableView.dequeueReusableCell(withIdentifier: "VendorCell", for: indexPath)
       cell.textLabel?.text = vendors[indexPath.row]
       return cell
// SwiftUI implementation
import SwiftUI
struct VendorListView: View {
   let vendors = ["Vendor 1", "Vendor 2", "Vendor 3"]
   var body: some View {
       NavigationView {
           List(vendors, id: \.self) { item in

2. Toggle is a key control on these screens 

It’s the main control for users to set up their preferences. There are also toggles that are automatically disabled when another toggle is disabled, such as “Enable all.”

Screenshot of enable all toggle for CMP solution

SwiftUI views reflect the state of your UI model. To code this “Enable all” functionality, all we need to do is to create a Binding<Bool> structure. This is where the getter reads if all other toggles are enabled in the model, and the setter sets all model toggles to a new value. 

Here’s a code example of this:

   content: {
       let allEnabledToggleBinding = Binding<Bool>(get: {
           return model.isAllEnabled()
       }, set: { newValue in
       Toggle("Enable All", isOn: allEnabledToggleBinding)

You can see how easy it’s to manage this logic with the power of reactive UI and bindings in SwiftUI.

3. Details inside expandable collection cells are hidden

We hide a lot of details inside expandable list rows to make our CMP screens look neat. Users still see the most important info on the top level of the screen. And if they need to go deeper, we let them expand each row. 

SwiftUI has an out-of-the-box expandable list row feature:

Screenshot showing expandable row feature in SwiftUI

In code, all we need to do to make the row expandable is to wrap the cell content into a DisclosureGroup view. 

Let’s look at the purpose rows example. This is where we attach detailed info to one of the purpose toggles:

ForEach($model.purposesList) { $purpose in
       content: {
       }, label: {
           Toggle($, isOn: $purpose.enabled)

SwiftUI lets us add this complex interactive functionality with just a few lines of codes. 

This MVP laid the foundation for the CMP later used in production. The declarative nature of SwiftUI also lets us adjust our UI really quickly for TCF compliance needs as we continued to develop the software.

One of the main challenges of using SwiftUI, however, is that it’s quite new and could still have some bugs and cause crashes – especially, on old platforms, like iOS 14. But after releasing the CMP feature, we found SwiftUI to be stable and have not encountered any bugs or crashes from it.

Implementing an CMP API

Building the UI part is just a half of the story. 

We then needed to collect user preferences, convert them to standardized format — TC String – and share it with the hosting app or advertisement services (vendors). 

A TC String’s primary purpose is to encapsulate and encode all the information disclosed to a user. According to TCF guidelines, the host app should provide users with an opportunity to change these choices. 

Every time a user is about to see the consent screen again, we need to take previously generated TC String and build our UI based on its value. This means that we need to have support for both encoding and decoding in our SDK.

screenshot showing the coding format for TC String

The coding format for TC String is well described in the IAB TCF docs. TC String is a base64 encoded list of bits; most of the time each bit tells us if the user has given us permission to use their data for a specific reason or vendor.

There is currently no open-source library available for generating TC String V2 in Swift. However, among the forks of the official repo, we found one that supports the encoding and decoding of TC String v1. We used this as a basis to create our own fork with support for TCF 2.2. We are currently working on opening a merge request on GitHub to merge our changes back to IAB’s repository.

The differences between TC String v1 and v2 are:

  • v2 has new fields with static size: TcfPolicyVersion, IsServiceSpecific, UseNonStandardTexts, PurposeOneTreatment, PublisherCC
  • v2 has new dynamic ranges of values: SpecialFeatureOptIns, PurposesLITransparency, Vendor Legitimate Interest Section

We added those new fields to the TC String coding algorithms of our fork. The public API provides you with two main classes: ConsentStringBuilderV2 and ConsentStringV2.

ConsentStringBuilderV2, as the name implies, is used for building TCString from plain values.

let tcString = try ConsentStringBuilderV2().build(
   cmpId: 0,
   cmpVersion: 0,
   consentScreenId: 0,
   consentLanguage: "EN",
   vendorListVersion: 153,
   tcfPolicyVersion: 4,
   useNonStandardTexts: 0,
   specialFeatureOptIns: [],
   purposeConsents: [1, 2, 3, 4],
   purposeLegitimateInterests: [2, 3],
   purposeOneTreatment: 1,
   publisherCC: "DE",
   allowedVendorIds: [1, 2, 3, 4],
   legitimateInterestsForVendorIds: [1, 2]

ConsentStringV2 is a class for reading plain values from TC String. In the example below, we use the same TC String value we generated:

let consentString = try ConsentStringV2(consentString: tcString)
       // 0
       // 1970-01-01 00:00:00 +0000
       // 1970-01-01 00:00:00 +0000
       // 50
       // EN
       // 153
       // 0
       // 1
       // 0
       // []
       // [4, 1 ,2 ,3]
       // [3, 2]
       // 1
       // EN
       // 667
       // true
       print(consentString.isVendorAllowed(vendorId: 1))
       // true
       print(consentString.isVendorAllowed(vendorId: 2))
       // true
       print(consentString.isVendorAllowed(vendorId: 3))
       // false
       print(consentString.isVendorAllowed(vendorId: 4))
       // false
       print(consentString.isVendorAllowed(vendorId: 5))
       // false   
       print(consentString.isLegitimateInterestForVendorAllowed(vendorId: 1))
       // true
       print(consentString.isLegitimateInterestForVendorAllowed(vendorId: 2))
       // true
       print(consentString.isLegitimateInterestForVendorAllowed(vendorId: 3))

If you are looking for a TC String v2 solution, feel free to try out our TC String V2 coding implementation

The base64-encoded list of bits is not something that you can easily verify and test. Fortunately, the IAB provides developers with a comprehensive tool that helps with the encoding and decoding of TC String value.

screenshot of using TC String decoding tool

Once TC String was generated, we needed to notify the host application or another ad SDK integrated within the app. This was necessary for the CMP API we had to implement. 

An iOS way to support the CMP API is to set the UserDefaults variables. With this approach, third parties are able to read TC String and other values from UserDefaults directly. Or they can even subscribe to be notified of UserDefaults changes as soon as the user has selected their choices on the CMP consent screens. 

Besides saving TC String to UserDefaults, we also had to save some other information about our CMP, as well as raw “1” and “0” strings that represent user consent. You can find the whole list of required values here

Here’s a code snippet:

UserDefaults.standard.setValue(0, forKey: "IABTCF_CmpSdkID")
UserDefaults.standard.setValue(0, forKey: "IABTCF_CmpSdkVersion")
UserDefaults.standard.setValue05, forKey: "IABTCF_PolicyVersion")
UserDefaults.standard.setValue(1, forKey: "IABTCF_gdprApplies")
UserDefaults.standard.setValue("DE", forKey: "IABTCF_PublisherCC")
UserDefaults.standard.setValue(0, forKey: "IABTCF_PurposeOneTreatment")
UserDefaults.standard.setValue(0, forKey: "IABTCF_UseNonStandardTexts")
UserDefaults.standard.setValue("1101", forKey: "IABTCF_VendorConsents")
UserDefaults.standard.setValue("011", forKey: "IABTCF_VendorLegitimateInterests")
UserDefaults.standard.setValue("111111111110000000000000", forKey: "IABTCF_PurposeConsents")
UserDefaults.standard.setValue("010000111100000000000000", forKey: "IABTCF_PurposeLegitimateInterests")
UserDefaults.standard.setValue("110000000000", forKey: "IABTCF_SpecialFeaturesOptIns")

After all these steps, the only thing left for us to do was to send TC String information to the WAVE mediation platform when our SDK makes an ad request. This way, the vendors can decode TC String value and see if they are allowed to use user data. 

The overall process looks like this:

diagram showing TC String generation flow

Lessons Learned about Implementing Our CMP Solution

The documentation provided by the IAB means that implementing a CMP API is straightforward. These resources offer clear guidelines, making it simple for developers to integrate and manage user consent within their digital platforms. 

Implementing a CMP solution presents a challenge; however, it ensures you have the flexibility to meet user needs while maintaining the highest standards of user data privacy. 

And while SwiftUI is relatively new and may potentially have bugs, it’s ready for production. Our SwiftUI CMP screens have been deployed across millions of devices and ensure a smooth crash-free user experience. Introducing them to your application or SDK is a good idea if you’re looking to expedite development.

We’re programmed to succeed

See vacancies