Storing Consent Preferences

You can persist your users' consent preferences with Preference Management, and synchronize consent preferences across devices and apps.

Transcend-hosted vs. Self-hosted Sombra: The instructions in this guide vary depending on your Sombra deployment type. If you're using Transcend's hosted Sombra service, you can retrieve encryption keys from the Admin Dashboard. If you're self-hosting Sombra, you'll need to generate your own encryption keys (we recommend using OpenSSL for consistency).

When a user has signed in on your application, your backend will serve a token to your frontend, which allows Transcend's SDK to save this authenticated user's consent preferences.

Go to the Preference Management Developer Settings page and toggle on the Preference Store. With that toggle flipped on and after your next airgap.js deploy, you will be able to start syncing user preferences.

The token you'll serve is a JSON Web Token (JWT) that carries encrypted identifier name/value pairs for this user.

On your backend, encrypt each identifier value with your Preference Store encryption key, put the encrypted values in the JWT payload, then sign the JWT with your signing key. Prefer the identifiers payload shape below. The legacy single encryptedIdentifier shape is still accepted but deprecated. Encryption and signing keys can be found in the Admin Dashboard. More info about these keys can be found here.

The encryption algorithm is AES-KWP. The signing algorithm is HMAC-SHA-384.

In addition to identifiers, you may include a creation or expiry date on the token's payload (see examples below). Ultimately this JWT can be stored locally on the user's device, and we recommend creating a new one for each logged-in session. Note: some libraries (ex. JWT in TypeScript) will automatically insert an issue time (iat) on your payload.

Normalize before encryption. Lowercase emails (and otherwise normalize identifiers) on your backend before AES-KWP encryption. Transcend never sees decrypted values and does not validate identifiers.

Encrypt, then sign. Encrypt each identifiers[].value with the Preference Store encryption key (AES-KWP, base64). Then put those encrypted values in the JWT payload and sign with the JWT signing key (HS384). Never put plaintext identifier values in the JWT.

Match your organization identifier setup. Each name must match an identifier configured for your organization in the Admin Dashboard (for example email, phone, or a custom identifier). Do not invent names that are not set up on the organization.

Recommended JWT payload shape (after each value is AES-KWP encrypted). Use identifier names that match your organization setup. At least one identifier must be unique for the partition:

{
  "identifiers": [
    {
      "name": "email",
      "value": "<AES-KWP encrypted value>"
    }
  ]
}
import * as crypto from 'crypto';
import * as jwt from 'jsonwebtoken';

type ConsentIdentifier = {
  name: string;
  value: string;
};

function createConsentToken(
  identifiers: ConsentIdentifier[],
  base64EncryptionKey: string,
  base64SigningKey: string,
): string {
  // Read on for where to find these keys
  const signingKey = Buffer.from(base64SigningKey, 'base64');
  const encryptionKey = Buffer.from(base64EncryptionKey, 'base64');

  // NIST's AES-KWP implementation { aes 48 } - see https://tools.ietf.org/html/rfc5649
  const encryptionAlgorithm = 'id-aes256-wrap-pad';
  // Initial Value for AES-KWP integrity check - see https://tools.ietf.org/html/rfc5649#section-3
  const iv = Buffer.from('A65959A6', 'hex');

  // 1) Normalize + encrypt EACH identifier value with the encryption key
  const encryptedIdentifiers = identifiers.map(({ name, value }) => {
    const normalized = name === 'email' ? value.toLowerCase() : value;
    const cipher = crypto.createCipheriv(
      encryptionAlgorithm,
      encryptionKey,
      iv,
    );
    const encryptedValue = Buffer.concat([
      cipher.update(normalized, 'utf8'),
      cipher.final(),
    ]).toString('base64');
    return { name, value: encryptedValue };
  });

  // 2) Put encrypted values in the JWT payload, then sign with the signing key
  // jwt.sign will add an 'iat' (issued at) field to the payload
  const jwtPayload = {
    identifiers: encryptedIdentifiers,
  };

  return jwt.sign(jwtPayload, signingKey, {
    algorithm: 'HS384',
  });
}

// Example:
// createConsentToken(
//   [{ name: 'email', value: 'User@Example.com' }],
//   base64EncryptionKey,
//   base64SigningKey,
// );

N.B. pip install cryptography pyjwt

import base64
import jwt
from cryptography.hazmat.primitives.keywrap import aes_key_wrap_with_padding
from datetime import datetime

def create_consent_token(
    identifiers: list[dict[str, str]],
    base64_encryption_key: str,
    base64_signing_key: str,
) -> str:
    # Read on for where to find these keys
    encryption_key = base64.b64decode(base64_encryption_key)
    signing_key = base64.b64decode(base64_signing_key)

    # 1) Normalize + encrypt EACH identifier value with the encryption key
    encrypted_identifiers = []
    for identifier in identifiers:
        name = identifier["name"]
        value = identifier["value"]
        normalized = value.lower() if name == "email" else value
        encrypted_value = aes_key_wrap_with_padding(
            encryption_key, str.encode(normalized)
        )
        encrypted_identifiers.append(
            {
                "name": name,
                "value": base64.b64encode(encrypted_value).decode(),
            }
        )

    # 2) Put encrypted values in the JWT payload, then sign with the signing key
    jwt_payload = {
        "identifiers": encrypted_identifiers,
        "iat": datetime.now(),
    }

    return jwt.encode(jwt_payload, signing_key, algorithm="HS384")

Deprecated. Existing integrations may still mint JWTs with a single encryptedIdentifier field. Prefer migrating to identifiers name/value pairs. The flow is the same conceptually: encrypt the identifier value with the encryption key, then sign the JWT with the signing key.

import * as crypto from 'crypto';
import * as jwt from 'jsonwebtoken';

function createConsentToken(
  userId: string,
  base64EncryptionKey: string,
  base64SigningKey: string,
): string {
  // Read on for where to find these keys
  const signingKey = Buffer.from(base64SigningKey, 'base64');
  const encryptionKey = Buffer.from(base64EncryptionKey, 'base64');

  // NIST's AES-KWP implementation { aes 48 } - see https://tools.ietf.org/html/rfc5649
  const encryptionAlgorithm = 'id-aes256-wrap-pad';
  // Initial Value for AES-KWP integrity check - see https://tools.ietf.org/html/rfc5649#section-3
  const iv = Buffer.from('A65959A6', 'hex');
  // Set up encryption algorithm
  const cipher = crypto.createCipheriv(encryptionAlgorithm, encryptionKey, iv);

  // Set an issued date for the token
  const issued: Date = new Date();

  // Encrypt the userId and base64-encode the result
  const encryptedIdentifier = Buffer.concat([
    cipher.update(userId),
    cipher.final(),
  ]).toString('base64');

  // Create the JWT content - jwt.sign will add a 'iat' (issued at) field to the payload
  // If you wanted to add something manually, consider
  // const issued: Date = new Date();
  // const isoDate = issued.toISOString();
  const jwtPayload = {
    encryptedIdentifier,
  };

  // Create a JSON web token and HMAC it with SHA-384
  const consentToken = jwt.sign(jwtPayload, signingKey, {
    algorithm: 'HS384',
  });

  return consentToken;
}

Notes:

  • PyJWT has namespace conflicts with JWT.
  • Avoid naming your scripts the same name as a library (for ex jwt.py).

N.B. pip install cryptography pyjwt

import base64
import jwt
from cryptography.hazmat.primitives.keywrap import aes_key_wrap_with_padding
from datetime import datetime

def create_consent_token(user_id: str, base64_encryption_key: str, base64_signing_key: str) -> str:
    # Read on for where to find these keys
    encryption_key = base64.b64decode(base64_encryption_key)
    signing_key = base64.b64decode(base64_signing_key)

    # encode user_id as bytes
    user_id_bytes = str.encode(user_id)

    # NIST's AES-KWP implementation { aes 48 } - see https://tools.ietf.org/html/rfc5649
    encrypted_identifier = aes_key_wrap_with_padding(encryption_key, user_id_bytes)

    # base64-encode the result
    base64_encrypted_identifier = base64.b64encode(encrypted_identifier)

    # Create the JWT content
    jwt_payload = { 'encryptedIdentifier': base64_encrypted_identifier.decode(),
                    'iat': datetime.now()}

    # Create a JSON web token and HMAC it with SHA-384
    consent_token = jwt.encode(jwt_payload, signing_key, algorithm="HS384")

    return consent_token

If you encounter errors using your token, you may need to verify that the token was generated correctly. Grab your token plus the encryption and signing keys from the Admin Dashboard. The sample below verifies the preferred identifiers shape (decrypting each encrypted value). Legacy tokens that only include encryptedIdentifier can be unwrapped the same way from that single field.

import * as jwt from 'jsonwebtoken';
import * as crypto from 'node:crypto';

function parseConsentToken(
  token: string,
  base64EncryptionKey: string,
  base64SigningKey: string,
): Array<{ name: string; value: string }> {
  // Decode keys
  const signingKey = Buffer.from(base64SigningKey, 'base64');
  const encryptionKey = Buffer.from(base64EncryptionKey, 'base64');

  // 1) Verify JWT + extract payload
  const payload = jwt.verify(token, signingKey, {
    algorithms: ['HS384'],
  }) as {
    identifiers?: Array<{ name: string; value: string }>;
    encryptedIdentifier?: string;
  };

  // NIST AES Key Wrap with Padding (RFC 5649)
  const encryptionAlgorithm = 'id-aes256-wrap-pad';
  const iv = Buffer.from('A65959A6', 'hex');

  const decrypt = (encryptedValue: string): string => {
    const encryptedBytes = Buffer.from(encryptedValue, 'base64');
    const decipher = crypto.createDecipheriv(
      encryptionAlgorithm,
      encryptionKey,
      iv,
    );
    return Buffer.concat([
      decipher.update(encryptedBytes),
      decipher.final(),
    ]).toString('utf8');
  };

  // Preferred: decrypt each identifiers[].value
  if (payload.identifiers?.length) {
    return payload.identifiers.map(({ name, value }) => ({
      name,
      value: decrypt(value),
    }));
  }

  // Deprecated: single encryptedIdentifier
  if (payload.encryptedIdentifier) {
    return [{ name: 'legacy', value: decrypt(payload.encryptedIdentifier) }];
  }

  throw new Error('Token payload missing identifiers or encryptedIdentifier');
}

Your server will generate the token and return it to the frontend (likely as part of the response payload for the login flow):

// When a user has authenticated
const consentToken = createConsentToken(
  userId,
  base64EncryptionKey,
  base64SigningKey
);

return {
  status: 200,
  body: {
    user: authenticatedUserData,
    consentToken,
  }
}

On the frontend, you will then pass this token to Transcend:

// Authenticate the user to airgap, and sync their consent with Transcend Preference Management
airgap.sync({ auth: consentToken });

The process for obtaining the encryptionKey and signingKey depends on whether you're using Transcend-hosted or self-hosted Sombra.

If you're using Transcend-hosted Sombra, the keys can be retrieved from the Admin Dashboard, under the "Developer Settings" section on the Preferences page.

  1. Toggle "on" the Preference Store feature
  2. Click on View Encryption Key to see the encryptionKey in the code example above. Save the contents somewhere safe!
  3. Click on View JWT Signing Key to see the signingKey in the code example above. Again, please save the contents somewhere safe!

If you're self-hosting Sombra, you need to generate your own encryption keys:

Generate the encryption key:

ENCRYPTION_KEY=$(openssl rand -base64 32)
echo "Use this as your encryptionKey: $ENCRYPTION_KEY"

Generate the JWT signing key:

JWT_SIGNING_KEY=$(openssl rand -base64 32)
echo "Use this as your signingKey: $JWT_SIGNING_KEY"

Alternative generation methods: While we recommend OpenSSL for consistency with other Transcend documentation, you can generate these keys using any method that produces cryptographically secure base64-encoded 32-byte keys.

Important for self-hosted Sombra:

  • You must configure the CONSENT_IDENTIFIER_ENCRYPTION_KEY environment variable in your Sombra deployment using the same value as your encryptionKey
  • Keep both keys secure and backed up, as losing them will make existing consent preferences unreadable
  • Use the same keys consistently across all your Sombra instances

For detailed instructions on configuring these environment variables, see the Sombra Environment Variables documentation.

The toggle which enables syncing with the Preference Management
  1. Make sure Reporting Only mode is off
  2. Your domain is included in the Domains List (e.g. localhost:3033 if you're using the backend-consent-example)
  3. Navigate to Regional Experiences and make sure that you are in a region with an experience defined. (e.g. if you are in New York, set an experience using either the region of New York or the New York time zone (or both) and add a purpose (e.g. Advertising) to it.)
  4. If you haven't already, copy the HTML Snippet into the page you want to test this on (using the Test bundle)

Now that you've collected the user consent from the web, you may need to look up their consent record as part of executing backend data processes. This can be easily done using our Sombra™ API.

Endpoint reference: Query User Consent

Your backend server may need to gate tracking/data-processing logic based on the user consent preferences. An example of how to do so for user with email foo@example.com:

const request = require('request');

const PARTITION = 'ee1a0845-694e-4820-9d51-50c7d0a23467'; // Admin Dashboard > Consent > Developer Settings

const result = request.post(
  `{{yourOrganizationSombraURL}}/v1/preferences/${PARTITION}/query`,
  {
    headers: {
      // use the scope for viewing Preference Store / Managed Consent Database Admin API
      authorization: 'Bearer {{apiKey}}',
      // only required for single-tenant Sombra
      'x-sombra-authorization': 'Bearer {{sombraApiKey}}',
      'content-type': 'application/json',
    },
    body: {
      filter: {
        identifiers: [
          { name: 'email', value: 'foo@example.com' },
        ],
      },
    },
    json: true,
  },
);

const consentPreference = result.nodes[0];

// May be undefined/empty list if the user has never given consent
const isPurposeEnabled = (record, purpose) =>
  record?.purposes?.find((entry) => entry.purpose === purpose)?.enabled;

if (isPurposeEnabled(consentPreference, 'Advertising')) {
  // The user has given consent to Advertising, perform some custom logic...
}

if (isPurposeEnabled(consentPreference, 'Functional') !== false) {
  // The user has NOT explicitly opted out of Functional tracking purpose, perform some custom logic...
}

A common scenario: your company uploads a list of users to Facebook once a week, and you need to filter this list to exclude users who have opted out of targeted advertising. Here's an example of how you might do so:

const request = require('request');

const PARTITION = 'ee1a0845-694e-4820-9d51-50c7d0a23467'; // Admin Dashboard > Consent > Developer Settings

const { nodes: userConsentPreferences } = request.post(
  `{{yourOrganizationSombraURL}}/v1/preferences/${PARTITION}/query`,
  {
    headers: {
      // use the scope for viewing Preference Store / Managed Consent Database Admin API
      authorization: 'Bearer {{apiKey}}',
      'x-sombra-authorization': 'Bearer {{sombraApiKey}}',
      'content-type': 'application/json',
    },
    body: {
      filter: {
        identifiers: userIdentifiers.map((value) => ({
          name: 'email',
          value,
        })),
      },
    },
    json: true,
  },
);

const isPurposeEnabled = (record, purpose) =>
  record?.purposes?.find((entry) => entry.purpose === purpose)?.enabled;

const usersToUpload = userIdentifiers.filter((identifier) => {
  const record = userConsentPreferences.find((data) =>
    data.identifiers?.some(
      (entry) => entry.name === 'email' && entry.value === identifier,
    ),
  );

  // Make sure the user has not explicitly opted out.
  // You may also require explicit opt-in instead.
  return (
    isPurposeEnabled(record, 'Advertising') !== false &&
    isPurposeEnabled(record, 'SaleOfInfo') !== false
  );
});

// Now you can safely upload `usersToUpload`

Use timestampAfter and timestampBefore to find preference records based on when the user set their consent (the preference timestamp field). This is different from filter.system.updatedAfter / updatedBefore, which filter on when the database record was last updated. You cannot combine identifier filters with timestamp filters in the same query.

const request = require('request');

const PARTITION = 'ee1a0845-694e-4820-9d51-50c7d0a23467'; // Admin Dashboard > Consent > Developer Settings

const timestampBefore = new Date().toISOString();
const timestampAfter = new Date(
  Date.now() - 1 * 24 * 60 * 60 * 1000,
).toISOString();

const { nodes: consentPreferences } = request.post(
  `{{yourOrganizationSombraURL}}/v1/preferences/${PARTITION}/query`,
  {
    headers: {
      // use the scope for viewing Preference Store / Managed Consent Database Admin API
      authorization: 'Bearer {{apiKey}}',
      'x-sombra-authorization': 'Bearer {{sombraApiKey}}',
      'content-type': 'application/json',
    },
    body: {
      // Preferences whose consent timestamp falls in the last 24 hours
      filter: {
        timestampAfter,
        timestampBefore,
      },
      limit: 50,
    },
    json: true,
  },
);

// Process records collected in that window
for (const record of consentPreferences) {
  // record.timestamp — when the user set this preference
  // record.identifiers, record.purposes, ...
}

To walk every preference record whose system metadata was updated in a window (for daily sync jobs), use filter.system.updatedAfter / updatedBefore with cursor pagination:

const request = require('request');

const PARTITION = 'ee1a0845-694e-4820-9d51-50c7d0a23467'; // Admin Dashboard > Consent > Developer Settings

async function paginateThroughConsentPreferences() {
  let cursor = undefined;
  const data = [];

  while (true) {
    const { nodes, cursor: nextCursor } = await request.post(
      `{{yourOrganizationSombraURL}}/v1/preferences/${PARTITION}/query`,
      {
        headers: {
          // use the scope for viewing Preference Store / Managed Consent Database Admin API
          authorization: 'Bearer {{apiKey}}',
          'x-sombra-authorization': 'Bearer {{sombraApiKey}}',
          'content-type': 'application/json',
        },
        body: {
          // Preferences updated in the last 24 hours (system metadata timestamps)
          filter: {
            system: {
              updatedBefore: new Date().toISOString(),
              updatedAfter: new Date(
                Date.now() - 1 * 24 * 60 * 60 * 1000,
              ).toISOString(),
            },
          },
          limit: 50,
          // Pass the opaque cursor from the previous response to fetch the next page
          cursor,
        },
        json: true,
      },
    );

    if (!nodes || nodes.length === 0) {
      break;
    }

    data.push(...nodes);

    // If there is no cursor, we have reached the end of the data
    if (!nextCursor) {
      break;
    }

    cursor = nextCursor;
  }

  return data;
}

// Call the function to start paginating through the API data
const consentPreferences = await paginateThroughConsentPreferences();

// Now you can loop through user preferences and use the data to trigger any internal processes!