Overview
AgileTrust Tokenization replaces sensitive data with format-preserving tokens — values that look identical to the original (same length, same structure, same character set) but are cryptographically secure substitutions.
Unlike traditional masking or hashing, tokenization is fully reversible. Given the same key, the original value can be recovered at any time without storing a lookup table.
AgileTrust Tokenization is built on FF3-1 (NIST SP 800-38G Revision 1) with AES-128, AES-192, or AES-256 — the same standard used by major payment processors and healthcare data vaults.
Key properties
- Format-preserving: a 16-digit credit card number tokenizes to another 16-digit number; a Chilean RUT like
13301430-6tokenizes to35240589-5. - Deterministic: same input + same key + same encoding + same tweak always produces the same token.
- Reversible:
/detokenizerecovers the original value with 100% fidelity. - Symbol-preserving: spaces, hyphens,
@,.,(), and all non-alphanumeric characters stay at their exact positions. - Schema-transparent: no database schema changes, no validation rule updates, no regex rewrites.
How It Works
Each tokenization request goes through three stages:
- Normalize — The input is NFC-normalized (Unicode Normalization Form C) to ensure consistent byte representation across platforms and locales.
- Split — The string is split into two lists: tokenizable characters (letters and digits from the chosen encoding's alphabet) and passthrough characters (everything else: spaces, hyphens, punctuation, symbols). Passthrough positions are recorded.
- Encrypt — The tokenizable characters are encrypted using FF3-1/AES. For inputs shorter than FF3's minimum (6 for numeric, 3 for text modes), a keyed SHA-256 substitution is used instead. For very long inputs, the tokenizable characters are processed in segments. The passthrough characters are then re-inserted at their original positions.
Detokenization runs the exact same pipeline in reverse — the cipher operates identically in both directions.
Encoding Modes
The encoding parameter controls which characters are considered
"tokenizable" and sets the encryption alphabet (radix).
| Mode | Alphabet | Max input length | Best for |
|---|---|---|---|
| numeric | 0123456789 (radix 10) |
16 characters | RUTs, phone numbers, credit cards, numeric IDs |
| latin1 | ~127 Latin-1 letters + digits (U+0021–U+00FF) | 256 characters | Western European names, addresses |
| utf8 (default) | ~256 Unicode letters + digits (BMP) | 256 characters | Any Unicode name, free text, email, multilingual data |
The same encoding must be used for both /tokenize and
/detokenize. Using a different encoding during detokenization will return
incorrect plaintext with no error — this is a security property of FPE.
Choosing the right mode
- Use
numericfor any value composed primarily of digits — even if it contains separators like hyphens or parentheses (they are preserved, not encrypted). - Use
utf8(default) for names, free-text fields, email addresses, and any string containing non-Latin characters. - Use
latin1for Western European names when you specifically need tokens that stay within the Latin-1 code page (e.g., legacy systems that cannot handle characters above U+00FF).
Quick Start
1. Run the container
export TOKENIZATION_KEY="$(python3 -c 'import os,base64; print(base64.b64encode(os.urandom(32)).decode())')" export API_KEY="$(openssl rand -hex 24)" docker run -d \ --name tokenizer \ -p 8000:8000 \ -e TOKENIZATION_KEY="$TOKENIZATION_KEY" \ -e API_KEY="$API_KEY" \ agiletrust/tokenization:0.3
The container starts a FastAPI server on port 8000. TOKENIZATION_KEY must be
a base64-encoded AES key — 16, 24, or 32 bytes (AES-128/192/256).
API_KEY is the shared secret required in the X-API-Key header.
2. Tokenize a value
curl -s -X POST http://localhost:8000/tokenize \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{"plaintext": "13301430-6", "encoding": "numeric"}' | jq .
{
"token": "35240589-5",
"algorithm": "FF3-1/AES",
"encoding": "numeric"
}
3. Detokenize to recover the original
curl -s -X POST http://localhost:8000/detokenize \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{"token": "35240589-5", "encoding": "numeric"}' | jq .
{
"plaintext": "13301430-6"
}
The hyphen in 13301430-6 is preserved at position 8 in both the
plaintext and the token. Only the digits are encrypted.
Authentication
All requests to /tokenize and /detokenize require an
X-API-Key header containing the shared API key.
The /health endpoint is open (required for liveness probes).
curl -s -X POST http://localhost:8000/tokenize \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{"plaintext": "13301430-6", "encoding": "numeric"}' | jq .
| Condition | HTTP status | Response body |
|---|---|---|
| Header absent | 401 Unauthorized | {"error": "Missing API key."} |
| Header present but wrong value | 403 Forbidden | {"error": "Invalid API key."} |
| Correct key | 200 OK | Token / plaintext |
In single-tenant mode, the API key is set via the API_KEY
environment variable and is shared across all callers. The AES encryption key is set via
TOKENIZATION_KEY. Neither key ever appears in logs or responses —
clients never send key material in request bodies.
In multi-tenant mode (MULTI_TENANT=true), API keys are
generated per application in the management console. Each key is stored as a SHA-256 hash
and resolves its own application-specific AES key from the database on every request.
See the Multi-Tenant & Console section for setup details.
Restrict network access to the tokenization container. It should never be exposed to the public internet. Deploy it inside a private VPC or behind an API gateway with mTLS.
GET /health
Returns service status and version. Use for liveness probes and uptime monitoring.
Response — 200 OK
{
"status": "ok",
"version": "0.3"
}
POST /tokenize
Converts a plaintext string into a format-preserving token.
Request body
| Field | Type | Description | |
|---|---|---|---|
| plaintext | required | string | The value to tokenize. May be empty (returns empty token). |
| encoding | optional | string enum | utf8 (default), latin1, or numeric. |
| tweak | optional | string | 14 hex chars (7 bytes). Field-level context for producing distinct tokens.
Defaults to 00000000000000. |
Response — 200 OK
| Field | Type | Description |
|---|---|---|
| token | string | Format-preserving token. Same length as NFC-normalized input. |
| algorithm | string | Always "FF3-1/AES". |
| encoding | string | The encoding mode used (echoed back). |
Response — 422 Unprocessable Entity
Returned for validation errors. The error field describes the issue:
| Cause | Error message |
|---|---|
Missing plaintext | plaintext: Field required |
| Unknown encoding | encoding: Value error, 'encoding' must be one of ['latin1', 'utf8', 'numeric']; got 'ascii'. |
| Numeric input > 16 chars | plaintext: Value error, 'plaintext' exceeds maximum length of 16 characters. |
| Text input > 256 chars | plaintext: Value error, 'plaintext' exceeds maximum length of 256 characters. |
| Invalid tweak format | tweak: Value error, 'tweak' must be exactly 14 hex characters (7 bytes). |
POST /detokenize
Reverses a token produced by /tokenize, recovering the original plaintext.
Wrong key or encoding returns HTTP 200 with incorrect plaintext. There is no error indicator. This is a fundamental security property of FPE algorithms — an attacker cannot tell whether detokenization succeeded or failed.
Request body
| Field | Type | Description | |
|---|---|---|---|
| token | required | string | Token returned by /tokenize. |
| encoding | optional | string enum | Must match the encoding used during tokenization. Default: utf8. |
| tweak | optional | string | Must match the tweak used during tokenization. Default: 00000000000000. |
Response — 200 OK
| Field | Type | Description |
|---|---|---|
| plaintext | string | The original value recovered from the token. |
Numeric — RUT, Phone, Credit Card
Use "encoding": "numeric" for any digit-heavy identifier. Separators are preserved.
curl -s -X POST http://localhost:8000/tokenize \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{"plaintext": "13301430-6", "encoding": "numeric"}' | jq .
{
"token": "35240589-5",
"algorithm": "FF3-1/AES",
"encoding": "numeric"
}
The hyphen at position 8 is preserved. Only the 9 digits are encrypted.
curl -s -X POST http://localhost:8000/tokenize \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{"plaintext": "(555) 123-4567", "encoding": "numeric"}' | jq .
{
"token": "(555) 089-2341",
"algorithm": "FF3-1/AES",
"encoding": "numeric"
}
Parentheses, space, and hyphen stay in place. All 10 digits are encrypted.
curl -s -X POST http://localhost:8000/tokenize \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{"plaintext": "4532015112830366", "encoding": "numeric"}' | jq .
{
"token": "7841392065194827",
"algorithm": "FF3-1/AES",
"encoding": "numeric"
}
16-digit PAN tokenizes to another 16-digit number — passes Luhn format checks in test environments.
Names and Free Text
curl -s -X POST http://localhost:8000/tokenize \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{"plaintext": "Juan Pérez-García"}' | jq .
{
"token": "Ñkrp Çmevq-Ĥapcw",
"algorithm": "FF3-1/AES",
"encoding": "utf8"
}
Space at position 4 and hyphen at position 10 preserved. All letters encrypted within the Unicode alphabet.
curl -s -X POST http://localhost:8000/tokenize \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{"plaintext": "Juan Pérez-García", "encoding": "latin1"}' | jq .
{
"token": "YrfãröxÍ-Ý",
"algorithm": "FF3-1/AES",
"encoding": "latin1"
}
Token stays within the Latin-1 character set (U+0000–U+00FF). Suitable for legacy systems.
import requests
BASE = "http://localhost:8000"
HEADERS = {"X-API-Key": "your-api-key-here"}
def tokenize(plaintext, encoding="utf8", tweak=None):
payload = {"plaintext": plaintext, "encoding": encoding}
if tweak:
payload["tweak"] = tweak
r = requests.post(f"{BASE}/tokenize", json=payload, headers=HEADERS)
r.raise_for_status()
return r.json()["token"]
def detokenize(token, encoding="utf8", tweak=None):
payload = {"token": token, "encoding": encoding}
if tweak:
payload["tweak"] = tweak
r = requests.post(f"{BASE}/detokenize", json=payload, headers=HEADERS)
r.raise_for_status()
return r.json()["plaintext"]
# Usage
token = tokenize("Juan Pérez-García")
print(token) # Ñkrp Çmevq-Ĥapcw
original = detokenize(token)
print(original) # Juan Pérez-García
# Numeric
rut_token = tokenize("13301430-6", encoding="numeric")
print(rut_token) # 35240589-5
const BASE = 'http://localhost:8000';
const HEADERS = { 'Content-Type': 'application/json', 'X-API-Key': 'your-api-key-here' };
async function tokenize(plaintext, encoding = 'utf8', tweak = null) {
const body = { plaintext, encoding };
if (tweak) body.tweak = tweak;
const res = await fetch(`${BASE}/tokenize`, {
method: 'POST',
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
return (await res.json()).token;
}
async function detokenize(token, encoding = 'utf8', tweak = null) {
const body = { token, encoding };
if (tweak) body.tweak = tweak;
const res = await fetch(`${BASE}/detokenize`, {
method: 'POST',
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
return (await res.json()).plaintext;
}
// Usage
const token = await tokenize('Juan Pérez-García');
console.log(token); // Ñkrp Çmevq-Ĥapcw
console.log(await detokenize(token)); // Juan Pérez-García
Email Addresses
The @ symbol and . are always preserved, making tokenized emails
syntactically valid in most validators.
curl -s -X POST http://localhost:8000/tokenize \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{"plaintext": "user@example.com", "encoding": "utf8"}' | jq .
{
"token": "ŋĭĕĭ@ĩźĝŋĪĮĩ.ĕĝĬ",
"algorithm": "FF3-1/AES",
"encoding": "utf8"
}
Use the tweak to produce different tokens for the same email address
in different field contexts (e.g., from_email vs reply_to).
Using Tweaks
A tweak is a 14-character hex string (7 bytes) that provides field-level context. The same plaintext tokenized with different tweaks produces completely different tokens, preventing an attacker from correlating tokens across fields.
# Tokenize as "name" field
curl -s -X POST http://localhost:8000/tokenize \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{"plaintext": "Maria Lopez", "tweak": "6e616d65000000"}' | jq .token
# Tokenize as "alias" field
curl -s -X POST http://localhost:8000/tokenize \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{"plaintext": "Maria Lopez", "tweak": "616c696173000000"}' | jq .token
Common tweak values
| Field | Tweak (hex) | Derivation |
|---|---|---|
| name | 6e616d65000000 | ASCII "name" + zero padding |
| rut | 72757400000000 | ASCII "rut" + zero padding |
656d61696c0000 | ASCII "email" + zero padding | |
| phone | 70686f6e650000 | ASCII "phone" + zero padding |
| addr | 61646472000000 | ASCII "addr" + zero padding |
Generate a tweak: echo -n "fieldname" | xxd -p | head -c 14 | awk '{printf "%s%0*d\n", $1, 14-length($1), 0}'
Limits & Constraints
| Encoding | Max total length | Note |
|---|---|---|
numeric | 16 characters | Applies to total string length incl. separators |
latin1 | 256 characters | NFC-normalized length |
utf8 | 256 characters | NFC-normalized length |
- Empty strings are accepted and return an empty token.
- Input is NFC-normalized before length is checked. Pre-normalize if exact limits matter.
- Tweak must be exactly 14 hexadecimal characters (case-insensitive). Defaults to
00000000000000.
Error Responses
All errors return JSON with a single error field:
{ "error": "plaintext: Value error, 'plaintext' exceeds maximum length of 16 characters." }
| HTTP status | Meaning |
|---|---|
| 200 | Success. Also returned for wrong key/encoding during detokenization (silent failure by design). |
| 422 | Validation error — missing required field, unknown encoding, input too long, or bad tweak format. |
| 500 | Internal server error. Check server logs. |
FAQ
Can two different plaintexts produce the same token?
No. FF3-1 is a pseudorandom permutation (PRP), so tokenization is a bijection — every plaintext maps to a unique token and vice versa, for a given key + tweak + encoding.
Does the token length always equal the input length?
Yes — the token length equals the length of the NFC-normalized input. If your input is already NFC, the lengths are identical.
What happens if I tokenize the same value twice?
You get the same token. Tokenization is deterministic. If you need different tokens for the same value in different contexts, use distinct tweaks.
Can I use the numeric encoding for credit card numbers?
Yes. A 16-digit PAN like 4532015112830366 will tokenize to another 16-digit number. Note that the tokenized number will not pass a Luhn check — that is expected and intentional.
How do I know if detokenization succeeded?
There is no cryptographic indicator. If you need to verify correctness, store a hash of the original plaintext and compare it after detokenization.
Is the service stateless?
The tokenization logic itself is stateless — no lookup table is stored and the original value is mathematically derived from the token + key + tweak + encoding. In multi-tenant mode, the service connects to PostgreSQL to resolve API keys and application configurations, but each request remains independent with no session state.
Single-Tenant Mode
The default operating mode. A single AES key is loaded at startup and shared across all requests. Suitable for a single application or when you manage isolation at the infrastructure level.
Required environment variables
| Variable | Required | Description |
|---|---|---|
| TOKENIZATION_KEY | Yes | Base64-encoded AES key (16, 24, or 32 bytes) |
| API_KEY | Yes | Shared secret for X-API-Key header |
| KEY_PROVIDER | No | Key source: env (default), aws_secretsmanager, aws_kms, azure_keyvault, gcp_secretmanager, oci_vault, ciphertrust |
| DEFAULT_TWEAK | No | 14 hex characters (7 bytes). Overrides the default all-zero tweak. |
export TOKENIZATION_KEY="$(python3 -c 'import os,base64; print(base64.b64encode(os.urandom(32)).decode())')" export API_KEY="$(openssl rand -hex 24)" docker run -d -p 8000:8000 \ -e TOKENIZATION_KEY="$TOKENIZATION_KEY" \ -e API_KEY="$API_KEY" \ tokenizer:0.3
Multi-Tenant Mode & Management Console
When MULTI_TENANT=true, the tokenizer resolves keys from PostgreSQL
on every request. Each application has its own AES key, configured via the
AgileTrust Console — a Next.js admin UI with SSO authentication.
Architecture
- Console (Next.js, port 3000): create tenants, applications, and API keys. Sign in with Google, Microsoft, or Okta.
- Tokenizer (FastAPI, port 8000): resolves the API key from the DB, decrypts the application's
provider_config, caches the key forKEY_CACHE_TTLseconds. - PostgreSQL: shared database. The console has read-write access; the tokenizer uses a read-only connection.
MASTER_ENCRYPTION_KEY must be identical on the console and the tokenizer.
It is the AES-256 key used to encrypt application provider_config in the database.
Never store it in the database itself.
Multi-tenant environment variables (tokenizer)
| Variable | Required | Description |
|---|---|---|
| MULTI_TENANT | Yes | "true" to enable |
| DATABASE_URL | Yes | PostgreSQL connection string (read-only recommended) |
| MASTER_ENCRYPTION_KEY | Yes | 32-byte AES-256 key, base64-encoded. Same as console. |
| KEY_CACHE_TTL | No | Seconds to cache per-application keys. Default: 300. |
Quick start (Docker Compose)
export POSTGRES_PASSWORD="$(openssl rand -hex 16)" export NEXTAUTH_SECRET="$(openssl rand -base64 32)" export MASTER_ENCRYPTION_KEY="$(python3 -c 'import os,base64; print(base64.b64encode(os.urandom(32)).decode())')" export GOOGLE_CLIENT_ID="your-client-id" export GOOGLE_CLIENT_SECRET="your-client-secret" # Run Prisma migration (first time only) cd console && npm install && npx prisma migrate deploy && cd .. # Start all 3 services docker compose -f docker-compose.full.yml up -d
First login flow
- Open
http://localhost:3000and sign in with your OAuth provider. - The first user for your email domain becomes owner of a new tenant. Subsequent users from the same domain are added as viewers.
- Go to Applications → New Application. Choose a key provider (System Vault is the default — no external vault needed).
- Open the application and click New Key. Copy the API key — it is shown only once.
- Use the API key in the
X-API-Keyheader when calling the tokenizer.
API key rotation vs. encryption key rotation
- API key rotation: generates a new
tok_…key for the same application. The encryption key and all tokens remain valid. - Encryption key rotation (System Vault only): generates a new AES key. All existing tokens become non-recoverable — FPE with the wrong key returns a plausible-looking but incorrect value without any error signal.
Key Providers
In single-tenant mode, select the provider with the KEY_PROVIDER env var on the tokenizer.
In multi-tenant mode, each application selects its own provider in the console — no env vars needed on the tokenizer.
| Provider | Description | Config keys |
|---|---|---|
| env | AES key from TOKENIZATION_KEY env var | — |
| system_vault | AES key stored encrypted in PostgreSQL (multi-tenant default) | auto-generated by console |
| aws_secretsmanager | AWS Secrets Manager | secret_id, region |
| aws_kms | AWS KMS (KMS-encrypted blob) | key_ref (base64), region |
| azure_keyvault | Azure Key Vault | vault_url, secret_name |
| gcp_secretmanager | GCP Secret Manager | secret_resource |
| oci_vault | OCI Vault | secret_id |
| ciphertrust | Thales CipherTrust Manager | Configuration guide coming soon |
Cloud provider SDKs are not installed by default. Build the image with the appropriate build arg:
docker build --build-arg KEY_PROVIDER_DEPS=aws .
Valid values: aws, azure, gcp, oci, ciphertrust.
env — Environment variable (default)
The AES key is supplied directly as a base64-encoded env var. Suitable for single-tenant deployments or local development.
| Mode | Variable / field | Value |
|---|---|---|
| Single-tenant | KEY_PROVIDER=envTOKENIZATION_KEY | Base64-encoded AES key (16, 24, or 32 bytes) |
| Multi-tenant | Provider config field: key | Base64-encoded AES key |
system_vault — Database-stored key (multi-tenant default)
The console generates a random AES-256 key at application creation time, encrypts it with MASTER_ENCRYPTION_KEY
using AES-256-GCM, and stores the ciphertext in the applications.provider_config column.
No external vault or cloud credentials are required.
This is the recommended starting point for most deployments. The key never leaves the database unencrypted.
Key rotation (Applications → [app] → Rotate Key) generates a new AES key. All tokens issued with the previous key become non-recoverable — FPE with the wrong key returns a plausible-looking but incorrect value with no error signal.
aws_secretsmanager — AWS Secrets Manager
The tokenizer calls GetSecretValue at startup (single-tenant) or on cache miss (multi-tenant) to retrieve the base64-encoded AES key stored as a plaintext secret.
| Mode | Variable / field | Value |
|---|---|---|
| Single-tenant | KEY_PROVIDER=aws_secretsmanagerAWS_SECRET_IDAWS_REGION | Secret ARN or name e.g. us-east-1 |
| Multi-tenant | secret_idregion | Secret ARN or name AWS region |
IAM permission required: secretsmanager:GetSecretValue on the secret ARN.
Use a task role (ECS), instance profile (EC2), or AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars.
KEY=$(python3 -c 'import os,base64; print(base64.b64encode(os.urandom(32)).decode())') aws secretsmanager create-secret \ --name agiletrust/tokenization-key \ --secret-string "$KEY" \ --region us-east-1
aws_kms — AWS KMS
The AES key is encrypted with an AWS KMS Customer Managed Key (CMK) and stored as a base64-encoded ciphertext blob.
The tokenizer calls kms:Decrypt to recover the plaintext key at runtime.
| Mode | Variable / field | Value |
|---|---|---|
| Single-tenant | KEY_PROVIDER=aws_kmsAWS_KMS_KEY_REFAWS_REGION | Base64 ciphertext from aws kms encryptAWS region |
| Multi-tenant | key_refregion | Base64 ciphertext AWS region |
IAM permission required: kms:Decrypt on the CMK.
KEY=$(python3 -c 'import os,base64; print(base64.b64encode(os.urandom(32)).decode())') CIPHERTEXT=$(aws kms encrypt \ --key-id alias/my-cmk \ --plaintext "$KEY" \ --query CiphertextBlob \ --output text \ --region us-east-1) # Store CIPHERTEXT as AWS_KMS_KEY_REF
azure_keyvault — Azure Key Vault
The AES key is stored as a secret in Azure Key Vault. Authentication uses azure-identity's DefaultAzureCredential, which supports managed identity, service principal, and developer credentials automatically.
| Mode | Variable / field | Value |
|---|---|---|
| Single-tenant | KEY_PROVIDER=azure_keyvaultAZURE_VAULT_URLAZURE_SECRET_NAME | https://my-vault.vault.azure.netSecret name in Key Vault |
| Multi-tenant | vault_urlsecret_name | Key Vault HTTPS URL Secret name |
RBAC: Grant the tokenizer's identity the Key Vault Secrets User role on the vault.
For service principal auth, set AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID.
gcp_secretmanager — GCP Secret Manager
The AES key is stored as a secret version in GCP Secret Manager. Authentication uses Application Default Credentials (ADC) — the service account running the container is granted access.
| Mode | Variable / field | Value |
|---|---|---|
| Single-tenant | KEY_PROVIDER=gcp_secretmanagerGCP_SECRET_RESOURCE | Full resource name, e.g.projects/my-project/secrets/tok-key/versions/latest |
| Multi-tenant | secret_resource | Full resource name |
IAM: Grant the service account the Secret Manager Secret Accessor role (roles/secretmanager.secretAccessor) on the secret.
oci_vault — OCI Vault
The AES key is stored as a secret in Oracle Cloud Infrastructure Vault. Authentication uses the OCI SDK config file or instance principal when running on OCI Compute.
| Mode | Variable / field | Value |
|---|---|---|
| Single-tenant | KEY_PROVIDER=oci_vaultOCI_SECRET_ID | Secret OCID, e.g.ocid1.vaultsecret.oc1.phx.xxx |
| Multi-tenant | secret_id | Secret OCID |
IAM: Grant the instance principal or dynamic group the manage secret-family permission in the vault's compartment.
Console Environment Variables
The management console is a Next.js application configured entirely through environment variables.
Set these in a .env file at the root of console/, or pass them directly to Docker.
| Variable | Required | Description |
|---|---|---|
| DATABASE_URL | Yes | PostgreSQL connection string. Example: postgresql://user:pass@host:5432/agiletrust |
| NEXTAUTH_URL | Yes | Public base URL of the console. Example: https://console.example.com. Used to construct OAuth callback URLs. |
| NEXTAUTH_SECRET | Yes | Random secret for signing session tokens. Generate with openssl rand -base64 32. |
| MASTER_ENCRYPTION_KEY | Yes | 32-byte AES-256 key in base64. Encrypts application provider_config in the database. Must be identical on the console and the tokenizer. |
| GOOGLE_CLIENT_ID | Optional* | Google OAuth 2.0 client ID. |
| GOOGLE_CLIENT_SECRET | Optional* | Google OAuth 2.0 client secret. |
| AZURE_AD_CLIENT_ID | Optional* | Microsoft Entra ID application (client) ID. |
| AZURE_AD_CLIENT_SECRET | Optional* | Microsoft Entra ID client secret. |
| AZURE_AD_TENANT_ID | Optional* | Microsoft Entra ID tenant ID (directory ID). |
| OKTA_CLIENT_ID | Optional* | Okta OIDC application client ID. |
| OKTA_CLIENT_SECRET | Optional* | Okta OIDC application client secret. |
| OKTA_ISSUER | Optional* | Okta authorization server URL. Example: https://your-org.okta.com/oauth2/default |
| KEYCLOAK_CLIENT_ID | Optional* | Keycloak client ID. |
| KEYCLOAK_CLIENT_SECRET | Optional* | Keycloak client secret. |
| KEYCLOAK_ISSUER | Optional* | Keycloak realm URL. Example: https://keycloak.example.com/realms/myrealm |
| POSTGRES_PASSWORD | No | Used only in the Docker Compose docker-compose.full.yml to set the PostgreSQL superuser password. |
* At least one OAuth provider must be configured.
All four core variables (DATABASE_URL, NEXTAUTH_URL, NEXTAUTH_SECRET, MASTER_ENCRYPTION_KEY) are always required. OAuth provider vars are required only for the providers you want to enable — you can enable multiple simultaneously.
SSO / Identity Providers
The console authenticates users via OAuth 2.0 / OIDC through NextAuth.js v5. Configure at least one of the four supported providers below. All enabled providers appear on the sign-in page simultaneously.
- Open Google Cloud Console → APIs & Services → Credentials.
- Click Create Credentials → OAuth 2.0 Client ID.
- Set application type to Web application.
- Under Authorized redirect URIs, add:
{NEXTAUTH_URL}/api/auth/callback/google - Copy the Client ID and Client Secret into your env vars.
| Env var | Where to find it |
|---|---|
| GOOGLE_CLIENT_ID | Credentials → OAuth 2.0 Client IDs → Client ID |
| GOOGLE_CLIENT_SECRET | Credentials → OAuth 2.0 Client IDs → Client Secret |
Microsoft Entra ID (Azure AD)
- Open Azure portal → Microsoft Entra ID → App registrations.
- Click New registration. Set a name; under Supported account types choose your desired scope.
- Under Redirect URI, select Web and enter:
{NEXTAUTH_URL}/api/auth/callback/azure-ad - After creation, note the Application (client) ID and Directory (tenant) ID.
- Go to Certificates & secrets → New client secret. Copy the secret value (not the ID).
| Env var | Where to find it |
|---|---|
| AZURE_AD_CLIENT_ID | App registration → Overview → Application (client) ID |
| AZURE_AD_CLIENT_SECRET | Certificates & secrets → Value |
| AZURE_AD_TENANT_ID | App registration → Overview → Directory (tenant) ID |
Okta
- Open Okta Admin Console → Applications → Create App Integration.
- Select OIDC – OpenID Connect as the sign-in method, then Web Application as the application type.
- Under Sign-in redirect URIs, add:
{NEXTAUTH_URL}/api/auth/callback/okta - Copy the Client ID and Client Secret from the application settings.
- The Issuer URL is shown in Security → API → Authorization Servers. Default:
https://your-org.okta.com/oauth2/default.
| Env var | Where to find it |
|---|---|
| OKTA_CLIENT_ID | Application settings → Client ID |
| OKTA_CLIENT_SECRET | Application settings → Client Secret |
| OKTA_ISSUER | Security → API → Authorization Servers → Issuer URI |
Keycloak
- Open your Keycloak Admin Console → select your realm → Clients → Create client.
- Set Client type to OpenID Connect and enter a Client ID.
- Enable Client authentication (confidential access type).
- Under Valid redirect URIs, add:
{NEXTAUTH_URL}/api/auth/callback/keycloak - Save. Go to the Credentials tab and copy the Client Secret.
- The issuer URL is your realm URL:
https://<host>/realms/<realm-name>.
| Env var | Where to find it |
|---|---|
| KEYCLOAK_CLIENT_ID | Clients → your client → Settings → Client ID |
| KEYCLOAK_CLIENT_SECRET | Clients → your client → Credentials → Client Secret |
| KEYCLOAK_ISSUER | https://<host>/realms/<realm-name> |
If Keycloak runs behind a reverse proxy, make sure KEYCLOAK_ISSUER matches the public URL. Keycloak's OIDC discovery endpoint must be reachable from the console at {KEYCLOAK_ISSUER}/.well-known/openid-configuration.
Tenant provisioning: The first user to sign in with a given email domain automatically becomes the owner of a new tenant (named after the domain). All subsequent users from the same domain are added as viewer. The owner can later promote any user to admin.
Roles & Permissions
The console enforces a three-level role hierarchy. A user with a higher-ranked role inherits all permissions of lower-ranked roles.
| Role | Rank | Capabilities |
|---|---|---|
| owner | 3 | Full control: manage tenant settings, create/edit/delete applications, manage API keys, manage users (change roles, remove), view audit log. |
| admin | 2 | Create/edit/delete applications, manage API keys (create, revoke, rotate). View users and audit log. Cannot manage user roles. |
| viewer | 1 | Read-only access to all resources. Cannot create or modify anything. |
- The first user to sign in for a given email domain becomes
owner. - Subsequent users from the same domain join as
viewer. - An owner can promote a viewer to admin, promote an admin to owner, or demote any user.
- An owner cannot remove or demote themselves if they are the last owner of the tenant.
Managing Applications
An application represents a logical service that will call the tokenizer API.
Each application has its own AES encryption key and generates its own API keys.
Requires admin or owner role.
Create an application
- Navigate to Applications in the sidebar.
- Click New Application.
- Enter a Name (required) and optional Description.
- Select a Key Provider.
system_vaultis the default — the console auto-generates and stores the AES key; no external vault credentials are needed. - For cloud providers, fill in the required config fields (e.g.,
secret_idandregionfor AWS Secrets Manager). - Click Create. The application is created and its key is provisioned immediately.
Key provider config fields (per provider)
| Provider | Fields shown in the console form |
|---|---|
| system_vault | None — key is auto-generated |
| env | key (base64-encoded AES key, password field) |
| aws_secretsmanager | secret_id, region |
| aws_kms | key_ref (base64 ciphertext, password field), region |
| azure_keyvault | vault_url, secret_name |
| gcp_secretmanager | secret_resource |
| oci_vault | secret_id |
Edit or delete an application
Open the application's detail page and use the Edit or Delete buttons.
Deleting an application immediately revokes all its API keys — any ongoing requests using those keys will receive 403 Forbidden.
Managing API Keys
API keys authenticate calls to the tokenizer. Each key is scoped to a single application and resolves that application's AES key.
Requires admin or owner role.
Create a key
- Open an application's detail page.
- Click New Key.
- Optionally enter a Label (e.g., production, staging) and an Expiry date.
- Click Generate. The full key is displayed exactly once — copy it immediately.
The API key plaintext is never stored. Only its SHA-256 hash is kept in the database. If you lose the key, revoke it and create a new one.
Key format: tok_{tenant_slug}_{32 random chars}
Example: tok_acmecorp_K7mP2xQnRvW9jLdF8bTsZeYhAcGiNuXo
Revoke a key
Click Revoke next to the key. The key stops working immediately — the tokenizer rejects it with 403 Forbidden on the next request.
Rotate a key
Click Rotate. This atomically generates a new key and revokes the old one. The new plaintext is shown once. Use rotation to replace a key that may have been exposed without disrupting service between generation and deployment.
Managing Users
Users join automatically — anyone who signs in with an email address belonging to the tenant's domain is added as a viewer.
No invitation step is required.
View users
Navigate to Users in the sidebar to see all users, their roles, last login time, and OAuth provider.
Change a user's role
Click the role badge next to a user to open the role selector. Requires owner. Available transitions:
viewer→adminorowneradmin→viewerorownerowner→adminorviewer(only if at least one other owner exists)
Remove a user
Click Remove next to the user. Requires owner. The user's session is invalidated and they cannot sign in again unless they re-authenticate (they would then rejoin as viewer).
A tenant must always have at least one owner. The last owner cannot demote or remove themselves.
Audit Log
Every mutating action performed through the console is recorded in the audit log. Navigate to Audit Log in the sidebar to view and paginate entries.
Each entry records: timestamp, actor (user name + email), IP address, action type, target resource, and an optional JSON details blob.
| Action | Target | Description |
|---|---|---|
| tenant.update | Tenant | Tenant name or settings changed |
| application.create | Application | New application created |
| application.update | Application | Application name, description, or provider config modified |
| application.delete | Application | Application and all its API keys deleted |
| application.rotate_key | Application | Encryption key (system_vault) rotated — existing tokens become non-recoverable |
| api_key.create | API Key | New API key generated for an application |
| api_key.revoke | API Key | API key revoked |
| api_key.rotate | API Key | API key rotated (new key generated, old key revoked) |
| user.invite | User | User invited (reserved for future invitation flow) |
| user.role_change | User | User's role changed |
| user.remove | User | User removed from tenant |
Audit log entries are append-only and cannot be deleted through the console. Requires admin or higher to view.