Docs/Guides/

Target List Encryption

Configure AES-256-GCM decryption for encrypted target lists in every Flagpool SDK

This guide covers encrypted target lists and complete crypto adapter setup for every current Flagpool SDK: JavaScript/TypeScript (browser and Node.js), React/Next.js, Python, Go, Java, and C#/.NET.

SDK setup guides

Use the links below to go directly to the complete, hands-on crypto adapter instructions for your SDK and runtime.

Why an adapter is needed

Target lists are named collections of string values with an attribute key, for example a list with attributeKey: "userId" and values: ["user-42"]. The dashboard can use a target list in an inTargetList or notInTargetList targeting rule. SDKs evaluate these rules against the matching field in the evaluation context.

When target-list encryption is enabled, the SDK export contains the target-list map as one AES-256-GCM encrypted object. The SDK itself deliberately has no platform crypto dependency: the application supplies an adapter that implements the SDK's decryption interface using the crypto APIs available in its runtime.

Two separate inputs are required for encrypted lists:

  • decryptionKey is passed to the client. All current SDKs require it; it is used to form the CDN URL and is also passed to the adapter.
  • The crypto adapter is registered before initialization. It decodes the key and decrypts the encrypted list payload.

Supplying only the key does not register a crypto implementation. Registering an adapter without supplying the same environment's key does not give it the key needed to decrypt.

No adapter is needed if the SDK receives no target lists, receives target lists in plaintext, or does not receive encrypted lists. Running an SDK on a server does not change this requirement: if that SDK receives encrypted lists, register an adapter in that server process too. With the dashboard's default encryption setting, an environment that exports encrypted lists needs an adapter even if the application does not intend to use a target-list rule: the SDK still receives the encrypted object and logs a warning. If no target-list rules are used, other flags continue to evaluate normally.

Without an adapter, each SDK skips decryption and emits a warning rather than treating the ciphertext as a list. Target-list-rule fallback behavior is not identical across SDKs and is not a safe substitute for decryption:

SDKMissing-adapter behavior for inTargetList / notInTargetList
JavaScript/TypeScript and ReactThe evaluator checks whether the rule's attribute exists in context. If it exists, either operator can match; otherwise the rule does not match and evaluation proceeds to lower-priority rules, rollout, or default.
PythonSame context-presence fallback as JavaScript: either operator can match when the rule attribute has a non-null value.
GoSame context-presence fallback as JavaScript: either operator can match when the rule attribute has a non-nil value.
JavaNeither operator matches while the target-list map is empty; evaluation proceeds to lower-priority rules, rollout, or default.
C# / .NETinTargetList does not match; notInTargetList matches.

These fallback rules do not inspect list membership. Configure the adapter to ensure the intended targeting rules are evaluated. React uses the TypeScript/JavaScript SDK evaluator and therefore inherits its warning and fallback behavior.

Disabling encryption is not the recommended production fix for a missing adapter. It changes the exported target-list values to plaintext for that environment. Implement and register the adapter instead; only consider plaintext exports for non-production environments or data that is explicitly approved for public distribution.

Security value and boundaries

Target lists can contain email addresses, user IDs, account identifiers, or other potentially sensitive identifiers. The dashboard models target-list values as strings and exports each list's attributeKey and values. Encrypting the exported target-list object reduces accidental or casual disclosure when SDK payloads are downloaded, inspected, cached, copied, or shared. It adds a data-protection layer around targeting data while allowing precise local evaluation without distributing list values in plaintext. It can support privacy and security reviews, but encryption alone does not establish regulatory compliance or satisfy any particular regulatory requirement.

Important security boundary: Only the target-list object is encrypted. Flag definitions, variations, operators, rule attributes, target-list references, and the rest of the SDK payload are not encrypted by target-list encryption.

A browser application receives the decryption key and the encrypted payload, then decrypts the list in that browser. A determined user who controls the client can inspect the key, payload, or decrypted memory and recover the values. Encrypted exports therefore do not keep target-list membership confidential from the client performing evaluation.

Feature flags and target-list rules are not authorization controls. Enforce access permissions, paid entitlements, and other security-sensitive decisions on your server. If target-list values must remain confidential from end users, evaluate on a server that keeps the key and decrypted lists out of browser-delivered code and data.

The JavaScript and .NET SDKs persist flag definitions in their local caches, not decrypted target-list values; React inherits the JavaScript cache behavior. The Python, Go, and Java SDK implementations do not persist target lists in an SDK cache. This is not a guarantee that all application caches, browser tools, logs, crash reports, or storage layers remain encrypted. Review your own runtime and storage paths.

Dashboard settings and key handling

In the dashboard, open Settings → Environments; the page is titled Environments & Keys. Find the relevant environment. Its row shows Decryption Key with Copy and Regenerate controls. The Target List Encryption toggle controls encryption for that environment; it is not a project-wide setting. Encryption defaults to enabled for new environments, and the application treats an unset legacy value as enabled.

Changing the toggle updates the environment export and re-exports flags. Regenerating the Decryption Key invalidates the old key immediately and changes the export URL hash; update every client using that environment with the new key. The key is generated as 32 random bytes, Base64 encoded, with the tlk_ prefix. You can copy it from Decryption Key in the environment settings.

Use the matching environment's Project ID, SDK API Key, and Decryption Key in the SDK configuration:

  • In a browser bundle, values passed to the browser SDK—including decryptionKey—are intentionally delivered to the client and can be recovered by its user. Do not put dashboard administrator credentials, database credentials, service-role keys, or other server-only secrets in browser code.
  • In a server process, load keys from server-side environment variables or a secret manager. Keep any server-only credentials on the server. If the target-list values must be secret from users, do not return the decryption key or decrypted lists to the browser.

Dashboard rule and context setup

For each example below, create or select a target list in Target Lists. Set its attribute to userId and include a placeholder value such as user-42. Create a boolean flag, for example beta-feature, with a rule that uses inTargetList, selects that target list, and serves the enabled variation when it matches. Set the rule's attribute to userId as well. The client context must then contain userId: "user-42" for the list member to match. Use notInTargetList to match non-members instead.

The adapter only returns the decrypted UTF-8 JSON string. The SDK detects the encrypted marker and algorithm, invokes the adapter, parses the returned JSON, and applies the rule; application code should not duplicate that parsing or evaluation.

Encryption contract

The dashboard's current export implementation and SDK payload format use:

Field / parameterContract
AlgorithmAES-256-GCM
Key32 raw bytes (256 bits), represented as standard Base64. Remove one optional leading tlk_ prefix before Base64 decoding. Do not run PBKDF2 or another key derivation step on dashboard-generated keys.
IV12 bytes, standard Base64
Authentication tag16 bytes (128 bits), standard Base64
CiphertextEncrypted bytes excluding the authentication tag, standard Base64
PlaintextUTF-8 JSON describing the target-list map

The response's targetLists property has _encrypted: true, _algorithm: "AES-256-GCM", _iv, _data (ciphertext), and _tag. Decoding Base64 is the adapter's job. Depending on the platform API, pass ciphertext and tag separately or concatenate them in that order. Preserve GCM authentication: reject invalid tags and propagate decryption failures. Decode successful plaintext as UTF-8 and return the string to the adapter interface.

This format reflects the dashboard's tlk_ key generation and exporter. Older text in the SDK crypto specification and adapter examples described PBKDF2; those instructions did not match dashboard-generated exports. The examples and specification have been aligned with the dashboard's actual 32-byte raw-key format. If integrating a separately produced legacy/custom payload, follow that producer's documented contract rather than changing these parameters silently.

JavaScript and TypeScript

Install the SDK:

npm install @flagpool/sdk

The Node.js package supports Node.js 18 or later. Browser use requires Web Crypto SubtleCrypto in a secure context (HTTPS, or localhost during development). Neither adapter requires an additional crypto package. For TypeScript Node applications that do not already include Node.js type definitions, add npm install -D @types/node for the node:crypto and Buffer types.

Browser Web Crypto adapter

Save as src/web-crypto-adapter.ts. Browser globals are only accessed inside decrypt, not while this module is imported.

import type { CryptoAdapter } from '@flagpool/sdk';

function decodeBase64(value: string, label: string): Uint8Array {
  if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
    throw new Error(`${label} is not valid standard Base64`);
  }
  const binary = atob(value);
  return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}

function getKeyBytes(key: string): Uint8Array {
  const encodedKey = key.startsWith('tlk_') ? key.slice(4) : key;
  const bytes = decodeBase64(encodedKey, 'decryption key');
  if (bytes.length !== 32) {
    throw new Error(`Expected a 32-byte AES-256 key; received ${bytes.length} bytes`);
  }
  return bytes;
}

function concatBytes(first: Uint8Array, second: Uint8Array): Uint8Array {
  const combined = new Uint8Array(first.length + second.length);
  combined.set(first);
  combined.set(second, first.length);
  return combined;
}

export const webCryptoAdapter: CryptoAdapter = {
  async decrypt(ciphertext, key, iv, tag) {
    if (!globalThis.crypto?.subtle) {
      throw new Error('Web Crypto SubtleCrypto is unavailable; use HTTPS or localhost');
    }
    const keyBytes = getKeyBytes(key);
    const ivBytes = decodeBase64(iv, 'IV');
    const ciphertextBytes = decodeBase64(ciphertext, 'ciphertext');
    const tagBytes = decodeBase64(tag, 'authentication tag');
    if (ivBytes.length !== 12) throw new Error('AES-GCM IV must be 12 bytes');
    if (tagBytes.length !== 16) throw new Error('AES-GCM authentication tag must be 16 bytes');

    const cryptoKey = await globalThis.crypto.subtle.importKey(
      'raw',
      keyBytes,
      { name: 'AES-GCM' },
      false,
      ['decrypt'],
    );
    const plaintext = await globalThis.crypto.subtle.decrypt(
      { name: 'AES-GCM', iv: ivBytes, tagLength: 128 },
      cryptoKey,
      concatBytes(ciphertextBytes, tagBytes),
    );
    return new TextDecoder('utf-8', { fatal: true }).decode(plaintext);
  },
};

Register the adapter before creating and initializing the client. Save the following as src/main.ts or your application's startup module:

import { FlagpoolClient, setCryptoAdapter } from '@flagpool/sdk';
import { webCryptoAdapter } from './web-crypto-adapter';

setCryptoAdapter(webCryptoAdapter);

const client = new FlagpoolClient({
  projectId: 'YOUR_PROJECT_UUID',
  apiKey: 'YOUR_ENVIRONMENT_API_KEY',
  decryptionKey: 'tlk_YOUR_BASE64_32_BYTE_KEY',
  context: { userId: 'user-42' },
});

await client.init();
const enabled = client.isEnabled('beta-feature');
console.log(enabled);

// On application shutdown:
client.close();

Node.js crypto adapter

Save as src/node-crypto-adapter.ts. Node's built-in node:crypto module is used; no additional package is needed.

import { createDecipheriv } from 'node:crypto';
import type { CryptoAdapter } from '@flagpool/sdk';

function decodeBase64(value: string, label: string): Buffer {
  const bytes = Buffer.from(value, 'base64');
  if (bytes.toString('base64') !== value) {
    throw new Error(`${label} is not valid canonical standard Base64`);
  }
  return bytes;
}

function getKeyBytes(key: string): Buffer {
  const encodedKey = key.startsWith('tlk_') ? key.slice(4) : key;
  const bytes = decodeBase64(encodedKey, 'decryption key');
  if (bytes.length !== 32) {
    throw new Error(`Expected a 32-byte AES-256 key; received ${bytes.length} bytes`);
  }
  return bytes;
}

export const nodeCryptoAdapter: CryptoAdapter = {
  async decrypt(ciphertext, key, iv, tag) {
    const keyBytes = getKeyBytes(key);
    const ivBytes = decodeBase64(iv, 'IV');
    const ciphertextBytes = decodeBase64(ciphertext, 'ciphertext');
    const tagBytes = decodeBase64(tag, 'authentication tag');
    if (ivBytes.length !== 12) throw new Error('AES-GCM IV must be 12 bytes');
    if (tagBytes.length !== 16) throw new Error('AES-GCM authentication tag must be 16 bytes');

    const decipher = createDecipheriv('aes-256-gcm', keyBytes, ivBytes);
    decipher.setAuthTag(tagBytes);
    const plaintext = Buffer.concat([
      decipher.update(ciphertextBytes),
      decipher.final(),
    ]);
    return new TextDecoder('utf-8', { fatal: true }).decode(plaintext);
  },
};

Save the client setup as src/main.ts in a Node.js process:

import { FlagpoolClient, setCryptoAdapter } from '@flagpool/sdk';
import { nodeCryptoAdapter } from './node-crypto-adapter';

setCryptoAdapter(nodeCryptoAdapter);

const client = new FlagpoolClient({
  projectId: 'YOUR_PROJECT_UUID',
  apiKey: process.env.FLAGPOOL_API_KEY!,
  decryptionKey: process.env.FLAGPOOL_DECRYPTION_KEY!,
  context: { userId: 'user-42' },
});

await client.init();
console.log(client.isEnabled('beta-feature'));

// Call when the process is shutting down.
client.close();

Keep server keys in the process environment or a secret manager. If you expose the evaluation result to a browser, do not expose the key or list unless that disclosure is intentional.

React and Next.js

Install React support:

npm install @flagpool/react

@flagpool/react includes @flagpool/sdk. A React adapter uses the same browser implementation as above; the component below shows a complete adapter module suitable for sharing in multiple Next.js applications. Put it at src/lib/flagpool-crypto-adapter.ts, or move the module unchanged into an internal shared TypeScript package imported by each application. It does not touch window, atob, or Web Crypto at import time.

import type { CryptoAdapter } from '@flagpool/react';

function decodeBase64(value: string, label: string): Uint8Array {
  if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
    throw new Error(`${label} is not valid standard Base64`);
  }
  return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
}

function concatBytes(first: Uint8Array, second: Uint8Array): Uint8Array {
  const result = new Uint8Array(first.length + second.length);
  result.set(first);
  result.set(second, first.length);
  return result;
}

export const webCryptoAdapter: CryptoAdapter = {
  async decrypt(ciphertext, key, iv, tag) {
    if (!globalThis.crypto?.subtle) {
      throw new Error('Web Crypto SubtleCrypto is unavailable; use HTTPS or localhost');
    }
    const encodedKey = key.startsWith('tlk_') ? key.slice(4) : key;
    const keyBytes = decodeBase64(encodedKey, 'decryption key');
    const ivBytes = decodeBase64(iv, 'IV');
    const ciphertextBytes = decodeBase64(ciphertext, 'ciphertext');
    const tagBytes = decodeBase64(tag, 'authentication tag');
    if (keyBytes.length !== 32) throw new Error('AES-256 key must be 32 bytes');
    if (ivBytes.length !== 12) throw new Error('AES-GCM IV must be 12 bytes');
    if (tagBytes.length !== 16) throw new Error('AES-GCM authentication tag must be 16 bytes');

    const cryptoKey = await globalThis.crypto.subtle.importKey(
      'raw',
      keyBytes,
      { name: 'AES-GCM' },
      false,
      ['decrypt'],
    );
    const plaintext = await globalThis.crypto.subtle.decrypt(
      { name: 'AES-GCM', iv: ivBytes, tagLength: 128 },
      cryptoKey,
      concatBytes(ciphertextBytes, tagBytes),
    );
    return new TextDecoder('utf-8', { fatal: true }).decode(plaintext);
  },
};

In Next.js App Router, create a client component such as app/flagpool-provider.tsx:

'use client';

import { FlagpoolProvider } from '@flagpool/react';
import type { ReactNode } from 'react';
import { webCryptoAdapter } from '@/lib/flagpool-crypto-adapter';

export function AppFlagpoolProvider({ children }: { children: ReactNode }) {
  return (
    <FlagpoolProvider
      projectId="YOUR_PROJECT_UUID"
      apiKey="YOUR_ENVIRONMENT_API_KEY"
      decryptionKey="tlk_YOUR_BASE64_32_BYTE_KEY"
      context={{ userId: 'user-42' }}
      cryptoAdapter={webCryptoAdapter}
    >
      {children}
    </FlagpoolProvider>
  );
}

Use AppFlagpoolProvider from the server-rendered root layout to wrap the client subtree. The FlagpoolProvider registers its cryptoAdapter before it calls client.init(). Keep the adapter import in the client-side component graph; do not import or invoke browser-only APIs from server components. A tlk_ key in this client provider is delivered to the browser, so it is not secret from its user. For confidential list contents, evaluate on the server instead.

useFlag('beta-feature') or useFlagDetails('beta-feature') can read the rule-backed flag after the provider is ready. On unmount, the provider closes and destroys its client; an application using FlagpoolClient directly should call client.close() during teardown.

Python

Install the SDK and cryptography (the SDK has no built-in crypto implementation):

pip install flagpool-sdk cryptography

Python 3.8 or later is required. Save as crypto_adapter.py:

import base64
import binascii

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from flagpool_sdk import CryptoAdapter


def decode_base64(value: str, label: str) -> bytes:
    try:
        return base64.b64decode(value, validate=True)
    except (binascii.Error, ValueError) as exc:
        raise ValueError(f"{label} is not valid standard Base64") from exc


class AesGcmCryptoAdapter(CryptoAdapter):
    def decrypt(self, ciphertext: str, key: str, iv: str, tag: str) -> str:
        encoded_key = key[4:] if key.startswith("tlk_") else key
        key_bytes = decode_base64(encoded_key, "decryption key")
        iv_bytes = decode_base64(iv, "IV")
        ciphertext_bytes = decode_base64(ciphertext, "ciphertext")
        tag_bytes = decode_base64(tag, "authentication tag")
        if len(key_bytes) != 32:
            raise ValueError("AES-256 key must be 32 bytes")
        if len(iv_bytes) != 12:
            raise ValueError("AES-GCM IV must be 12 bytes")
        if len(tag_bytes) != 16:
            raise ValueError("AES-GCM authentication tag must be 16 bytes")

        plaintext = AESGCM(key_bytes).decrypt(iv_bytes, ciphertext_bytes + tag_bytes, None)
        return plaintext.decode("utf-8", errors="strict")

Save the following as main.py. Register the adapter before constructing and initializing the client:

from flagpool_sdk import FlagpoolClient, set_crypto_adapter

from crypto_adapter import AesGcmCryptoAdapter

set_crypto_adapter(AesGcmCryptoAdapter())

client = FlagpoolClient(
    project_id="YOUR_PROJECT_UUID",
    api_key="YOUR_ENVIRONMENT_API_KEY",
    decryption_key="tlk_YOUR_BASE64_32_BYTE_KEY",
    context={"userId": "user-42"},
)

client.init()
print(client.is_enabled("beta-feature"))

# Call when the application is shutting down.
client.close()

Go

Install the SDK package:

go get github.com/flagpool/flagpool-sdk-go/flagpool

The current SDK module declares Go 1.24 or later. The adapter uses Go's standard crypto/aes, crypto/cipher, and encoding/base64 packages; it does not need an additional crypto dependency.

Save as crypto_adapter.go in your main package:

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"encoding/base64"
	"fmt"
	"unicode/utf8"

	"github.com/flagpool/flagpool-sdk-go/flagpool"
)

type AesGcmCryptoAdapter struct{}

func decodeBase64(value, label string) ([]byte, error) {
	decoded, err := base64.StdEncoding.Strict().DecodeString(value)
	if err != nil {
		return nil, fmt.Errorf("%s is not valid standard Base64: %w", label, err)
	}
	return decoded, nil
}

func (AesGcmCryptoAdapter) Decrypt(ciphertext, key, iv, tag string) (string, error) {
	encodedKey := key
	if len(encodedKey) >= 4 && encodedKey[:4] == "tlk_" {
		encodedKey = encodedKey[4:]
	}
	keyBytes, err := decodeBase64(encodedKey, "decryption key")
	if err != nil {
		return "", err
	}
	ivBytes, err := decodeBase64(iv, "IV")
	if err != nil {
		return "", err
	}
	ciphertextBytes, err := decodeBase64(ciphertext, "ciphertext")
	if err != nil {
		return "", err
	}
	tagBytes, err := decodeBase64(tag, "authentication tag")
	if err != nil {
		return "", err
	}
	if len(keyBytes) != 32 {
		return "", fmt.Errorf("AES-256 key must be 32 bytes")
	}
	if len(ivBytes) != 12 {
		return "", fmt.Errorf("AES-GCM IV must be 12 bytes")
	}
	if len(tagBytes) != 16 {
		return "", fmt.Errorf("AES-GCM authentication tag must be 16 bytes")
	}

	block, err := aes.NewCipher(keyBytes)
	if err != nil {
		return "", fmt.Errorf("create AES cipher: %w", err)
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", fmt.Errorf("create AES-GCM: %w", err)
	}
	plaintext, err := gcm.Open(nil, ivBytes, append(ciphertextBytes, tagBytes...), nil)
	if err != nil {
		return "", fmt.Errorf("AES-GCM authentication/decryption failed: %w", err)
	}
	if !utf8.Valid(plaintext) {
		return "", fmt.Errorf("decrypted target lists are not valid UTF-8")
	}
	return string(plaintext), nil
}

var _ flagpool.CryptoAdapter = AesGcmCryptoAdapter{}

Save the client setup as main.go in the same package:

package main

import (
	"fmt"
	"log"

	"github.com/flagpool/flagpool-sdk-go/flagpool"
)

func main() {
	flagpool.SetCryptoAdapter(AesGcmCryptoAdapter{})

	client := flagpool.NewClient(flagpool.ClientOptions{
		ProjectID:     "YOUR_PROJECT_UUID",
		APIKey:        "YOUR_ENVIRONMENT_API_KEY",
		DecryptionKey: "tlk_YOUR_BASE64_32_BYTE_KEY",
		Context: flagpool.Context{
			"userId": "user-42",
		},
	})
	if err := client.Init(); err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	fmt.Println(client.IsEnabled("beta-feature"))
}

Close() stops polling and cleans up the client. Keep the decryption key in server-side configuration for server applications.

Java

Add the SDK dependency (Java 11 or later):

<dependency>
    <groupId>io.flagpool</groupId>
    <artifactId>flagpool-sdk</artifactId>
    <version>0.2.1</version>
</dependency>

Gradle:

implementation 'io.flagpool:flagpool-sdk:0.2.1'

Save as AesGcmCryptoAdapter.java in your application package:

package example;

import io.flagpool.sdk.CryptoAdapter;

import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public final class AesGcmCryptoAdapter implements CryptoAdapter {
    private static byte[] decodeBase64(String value, String label) {
        try {
            return Base64.getDecoder().decode(value);
        } catch (IllegalArgumentException exception) {
            throw new IllegalArgumentException(label + " is not valid standard Base64", exception);
        }
    }

    @Override
    public String decrypt(String ciphertext, String key, String iv, String tag) throws Exception {
        String encodedKey = key.startsWith("tlk_") ? key.substring(4) : key;
        byte[] keyBytes = decodeBase64(encodedKey, "decryption key");
        byte[] ivBytes = decodeBase64(iv, "IV");
        byte[] ciphertextBytes = decodeBase64(ciphertext, "ciphertext");
        byte[] tagBytes = decodeBase64(tag, "authentication tag");
        if (keyBytes.length != 32) throw new IllegalArgumentException("AES-256 key must be 32 bytes");
        if (ivBytes.length != 12) throw new IllegalArgumentException("AES-GCM IV must be 12 bytes");
        if (tagBytes.length != 16) throw new IllegalArgumentException("AES-GCM authentication tag must be 16 bytes");

        byte[] combined = ByteBuffer.allocate(ciphertextBytes.length + tagBytes.length)
            .put(ciphertextBytes)
            .put(tagBytes)
            .array();
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"),
            new GCMParameterSpec(128, ivBytes));
        byte[] plaintext = cipher.doFinal(combined);
        return StandardCharsets.UTF_8.newDecoder()
            .onMalformedInput(CodingErrorAction.REPORT)
            .onUnmappableCharacter(CodingErrorAction.REPORT)
            .decode(ByteBuffer.wrap(plaintext))
            .toString();
    }
}

Save as Main.java in the same package. Register the adapter before init():

package example;

import io.flagpool.sdk.FlagpoolClient;
import io.flagpool.sdk.Types.ClientOptions;

import java.util.Map;

public final class Main {
    public static void main(String[] args) throws Exception {
        FlagpoolClient.setCryptoAdapter(new AesGcmCryptoAdapter());
        FlagpoolClient client = new FlagpoolClient(
            new ClientOptions()
                .setProjectId("YOUR_PROJECT_UUID")
                .setApiKey("YOUR_ENVIRONMENT_API_KEY")
                .setDecryptionKey("tlk_YOUR_BASE64_32_BYTE_KEY")
                .setContext(Map.of("userId", "user-42"))
        );
        try {
            client.init();
            System.out.println(client.isEnabled("beta-feature"));
        } finally {
            client.close();
        }
    }
}

The adapter throws on invalid input or failed authentication; the SDK must not receive a fabricated empty target list. close() stops scheduled polling and cleans up analytics.

C# / .NET

Install the package; the SDK targets .NET 6, .NET 8, and .NET 10:

dotnet add package Flagpool.Sdk

Save the following as Program.cs. ICryptoAdapter, FlagpoolCrypto, FlagpoolClient, and FlagpoolClientOptions are the SDK's public API; the adapter class is application-owned.

using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Flagpool.Sdk;

internal sealed class AesGcmCryptoAdapter : ICryptoAdapter
{
    private static byte[] DecodeBase64(string value, string label)
    {
        try
        {
            return Convert.FromBase64String(value);
        }
        catch (FormatException exception)
        {
            throw new FormatException($"{label} is not valid standard Base64", exception);
        }
    }

    public Task<string> DecryptAsync(string ciphertext, string key, string iv, string tag)
    {
        var encodedKey = key.StartsWith("tlk_", StringComparison.Ordinal) ? key[4..] : key;
        var keyBytes = DecodeBase64(encodedKey, "decryption key");
        var ivBytes = DecodeBase64(iv, "IV");
        var ciphertextBytes = DecodeBase64(ciphertext, "ciphertext");
        var tagBytes = DecodeBase64(tag, "authentication tag");
        if (keyBytes.Length != 32) throw new ArgumentException("AES-256 key must be 32 bytes");
        if (ivBytes.Length != 12) throw new ArgumentException("AES-GCM IV must be 12 bytes");
        if (tagBytes.Length != 16) throw new ArgumentException("AES-GCM authentication tag must be 16 bytes");

        var plaintext = new byte[ciphertextBytes.Length];
        using var aes = new AesGcm(keyBytes, tagBytes.Length);
        aes.Decrypt(ivBytes, ciphertextBytes, tagBytes, plaintext);
        return Task.FromResult(new UTF8Encoding(false, true).GetString(plaintext));
    }
}

internal static class Program
{
    private static async Task Main()
    {
        FlagpoolCrypto.SetCryptoAdapter(new AesGcmCryptoAdapter());

        using var client = new FlagpoolClient(new FlagpoolClientOptions
        {
            ProjectId = "YOUR_PROJECT_UUID",
            ApiKey = "YOUR_ENVIRONMENT_API_KEY",
            DecryptionKey = "tlk_YOUR_BASE64_32_BYTE_KEY",
            Context = new Dictionary<string, object?>
            {
                ["userId"] = "user-42",
            },
        });

        await client.InitAsync();
        Console.WriteLine(client.IsEnabled("beta-feature"));
    }
}

AesGcm.Decrypt authenticates the tag and throws on failure. using disposes the client and stops polling; await client.InitAsync() must complete before evaluation.

Troubleshooting

SymptomRoot cause and fix
Encrypted target lists received but no crypto adapter configured.Register the correct adapter before init() / Init() / InitAsync(), and pass the matching environment decryptionKey as well. Do not suppress the warning or make plaintext exports the default fix.
Adapter was registered after initializationInitialization fetches and processes the export. Register first, then create/init the client; for React, pass cryptoAdapter to FlagpoolProvider rather than registering in a later component effect.
Wrong key or tlk_ prefixCopy the Decryption Key for the same environment. The optional literal tlk_ prefix is removed before decoding; keep the Base64 part unchanged. Do not PBKDF2-derive a dashboard-generated key. Regeneration immediately invalidates the previous key.
Invalid Base64, key, IV, tag, or ciphertextCheck for truncation, whitespace, URL-safe Base64 substitutions, wrong environment values, and exact lengths: 32-byte key, 12-byte IV, and 16-byte tag. _data excludes the tag; _tag is separate.
GCM authentication/decryption failureGCM rejects modified ciphertext, tag, IV, or key. Ensure ciphertext and tag are combined only in the format required by the platform API, then surface the thrown/rejected error. Do not return an empty list or skip tag validation.
Web Crypto is unavailableBrowser Web Crypto requires a secure context (HTTPS or localhost). Run in a supported browser and do not execute crypto.subtle during server-side rendering.
Next.js client/server error or hydration issueKeep the provider in a 'use client' component, pass the adapter with the cryptoAdapter prop, and keep imports free of browser-global calls at module load. Keys placed in that component are exposed to browser users.
Python cannot import cryptographyInstall the optional adapter dependency with pip install cryptography. The SDK's base installation does not install it.
Go compilation reports a toolchain/version problemThe current Go SDK module requires Go 1.24. The adapter itself uses only the standard library.
Java or .NET crypto provider errorsUse Java 11+ and an installed AES/GCM provider, or a supported .NET target. Do not catch authentication exceptions and return an empty/success-shaped result.

If an adapter throws during decryption, behavior of the surrounding SDK initialization/polling path differs by language. Diagnose its emitted error or log, fix the key or payload, and retry initialization; do not interpret a missing target-list result as a successful membership check. The SDKs do not all propagate every asynchronous refresh error identically.

Related documentation

Start Managing Feature Flags Today

Join teams who trust Flagpool to deliver features safely and efficiently.

No credit card required • Cancel anytime