Data protection
Replace card numbers, SSNs, emails, phone numbers and free text with tokens, and read the values back only per profile and stated purpose.
Once a card number or a national identifier lands in one table, it spreads: into logs, analytics exports, backups, support tools and every service that reads the table. Each copy widens what an attacker can steal and what an audit must cover, and erasing one person's data means finding every copy.
Tokenization keeps sensitive values out of your application's databases, logs and analytics. Card numbers, national identifiers, email addresses, phone numbers and free-text fields are replaced by tokens, which are safe to store, index and pass between services. The values themselves are encrypted under a tenant KMS key. Turning a token back into its value (detokenizing) is a separate permission, decided per profile and per stated purpose, and every call is audited. The model follows Skyflow, VGS and the de-identification side of Google DLP.
await iam.api.protection.createProfile(admin, {
tenantId,
name: 'cards',
dataType: 'card',
deterministic: true,
});
const { tokens } = await iam.api.protection.tokenize(checkout, {
tenantId,
profile: 'cards',
values: ['4242 4242 4242 4242'],
});
// tokens[0] looks like '7304918265534242': same length, same last four digits, never a valid card number.
const { values } = await iam.api.protection.detokenize(paymentService, {
tenantId,
profile: 'cards',
tokens,
purpose: 'payment-processing',
});Over HTTP the same calls are POST {basePath}/protection/{method}, and @better-iam/client exposes them as
client.protection.*.
Profiles
A profile says how one kind of value is handled. Its name is how refer to it:
iam/protection/{name}. A tenant keeps at most 100 profiles. createProfile (iam:protection:manage) takes:
Prop
Type
The data type, format and key never change after the profile is created, because issued tokens depend on them.
updateProfile changes only the description, the mask and the retention (retentionDays: null keeps tokens until
they are deleted). getProfile and listProfiles report how many tokens each profile holds (tokens).
Values and tokens by data type
dataType | Accepted values | Format-preserving token |
|---|---|---|
card | 12 to 19 digits (spaces and dashes ignored), Luhn-valid | Same length and last four digits, and never Luhn-valid |
ssn | 9 digits, stored as NNN-NN-NNNN | 9XX-, group digits outside the ITIN ranges, and the last four: never a valid SSN or ITIN |
email | An email address, stored in Unicode NFC and lowercase | A random local part at the same domain |
phone | 7 to 15 digits with an optional +, compared as written (give them in E.164) | Same length, leading + and last four digits |
generic | 1 to 4096 characters of well-formed text, stored in NFC; tab and line feed allowed | Each letter of any script replaced by an ASCII letter (capitals by capitals), each digit by a digit, everything else kept |
A token is never the value itself. A format-preserving generic value needs at least 12 letters or digits, so its
token has enough random positions; use a random profile for shorter text.
Email tokens look deliverable
A format-preserving email token is an address at the real domain. Never send mail to tokens.
Values are normalized before they are tokenized. 4242 4242 4242 4242 and 4242424242424242 are the same card, and
composed and decomposed spellings of José are the same text, so a deterministic profile gives them the same token,
erasure by value finds both, and detokenizing returns the normalized form. Phone numbers are not reformatted:
+14155550199 and 14155550199 are different values.
Control characters (DEL and the C1 range included), unpaired surrogates and bidirectional formatting characters are refused: they would store or display as something other than what was sent.
Masks
dataType | Masks | Default |
|---|---|---|
card | last4, first6last4, full | last4 |
ssn | last4, full | last4 |
email | email (j***@example.com), full | email |
phone | last4, full | last4 |
generic | full, last4 | full |
A mask never shows more than half of a value's letters and digits: a seven-digit phone number shows its last three.
The one exception is first6last4 on a card number of 15 digits or more, which PCI DSS allows; on a shorter card
number it shows the last four only. The email mask shows the first character of the local part only when it has
four or more. Letters and digits of every script are hidden.
Tokenizing and reading back
tokenizetakes up to 100 values of one profile and returns their tokens in order. A deterministic profile returns the token it already issued for a value; otherwise every call issues new tokens. All the new values of one call share one data key, which is wrapped under the profile's KMS key. The response does not say which values were already stored (that would tell the caller who else is); the audit event records how many were new.detokenizetakes up to 100 tokens of one profile and apurpose, and returns the values in order, withnullfor tokens the profile does not hold. The purpose is a short lowercase name (up to 64 lowercase letters, digits and hyphens, starting with a letter) such aspayment-processing,fraud-reviewortax-filing.maskreturns up to 100 masked values (************4242,j***@example.com), with the profile's mask or astyleits data type allows. It is a separate permission, so support staff can recognize a card without ever seeing it.deleteTokenserases tokens and their values for good, named by up to 100tokensorvalues, and returns{ deleted }. Erasing by value finds every token issued for it through a keyed fingerprint, even in profiles that are not deterministic, which is what a data-subject erasure request needs. It needs the profile's KMS key enabled; erasing by token does not.
const { values: masked } = await iam.api.protection.mask(supportAgent, {
tenantId,
profile: 'cards',
tokens,
});
// ['************4242']
await iam.api.protection.deleteTokens(privacyHandler, {
tenantId,
profile: 'cards',
values: ['4242 4242 4242 4242'],
});Permissions
| Action | Resource | Calls |
|---|---|---|
iam:protection:manage | iam/protection/{name} | createProfile, updateProfile, deleteProfile |
iam:protection:read | iam/protection/{name} | listProfiles (per profile), getProfile |
iam:protection:tokenize | iam/protection/{name} | tokenize |
iam:protection:detokenize | iam/protection/{name} | detokenize |
iam:protection:mask | iam/protection/{name} | mask |
iam:protection:delete | iam/protection/{name} | deleteTokens, and setting or shortening retentionDays in updateProfile (with manage) |
can use resource.profile, resource.dataType, resource.format and
resource.deterministic. detokenize adds resource.purpose, and mask adds resource.style: the style actually
used, the profile's default included.
{
"effect": "allow",
"actions": ["iam:protection:detokenize"],
"resources": ["iam/protection/cards"],
"conditions": { "StringEquals": { "resource.purpose": "payment-processing" } }
}Tokenizing and detokenizing are separate so that the services that collect data (checkout forms, import jobs) can be allowed to tokenize without ever being able to read anything back. Callers from another tenant (other than a root administrator) and sessions that view as someone else () are refused.
Tokenizing into a deterministic profile is a lookup
Whoever may tokenize can submit guesses and compare the tokens with ones they hold. A format-preserving SSN token
shows the last four digits, which leaves 100,000 candidates: one thousand calls of 100 values. Grant
iam:protection:tokenize on deterministic profiles as narrowly as detokenize (conditions can name
resource.deterministic), and watch the iam:protection:tokenize audit events for volume.
Setting or shortening a profile's retention in updateProfile deletes tokens at the next sweep, so it needs
iam:protection:delete as well as iam:protection:manage, and a
, like deleting the profile.
Customer keys
A profile bound to an existing key (keyId) leaves the key's owner in charge, as the
secrets vault does. Every call needs the caller's own KMS permission on
the key, by policy, besides the protection one: iam:kms:generate-data-key to tokenize, iam:kms:decrypt to
detokenize or mask. The key must be an enabled aes-256-gcm key, and whoever creates the profile must hold both
permissions on it. Revoking those permissions, or disabling the key, stops the profile.
A key the profile created is managed by it (managedBy: 'protection'): the KMS API refuses to use it directly
(KEY_MANAGED), and deleting the profile schedules the key's deletion after 7 days. A customer key stays when the
profile is deleted. A key another profile or module manages cannot be bound.
How values are protected
- Each call that stores new values generates one data key, wraps it under the profile's AES KMS key (bound to the
profile), and encrypts each value with AES-256-GCM. The associated data names the tenant, the profile and the
token, so a stored ciphertext cannot be moved to another token. A stored value that fails authentication (altered
in storage) is reported as
KEY_MATERIAL_UNAVAILABLE. - Every token stores an HMAC fingerprint of its value, in every profile: deterministic profiles find existing tokens
by it, and erasure by value finds every token of a value. The fingerprint key is a data key wrapped under the
profile's KMS key, so it stops with the key, and without it fingerprints cannot be tested against guesses. Its
material lives in KMS, which
iam.rotateSecrets()re-seals, so tokens stay the same across secret rotations. - Disabling the KMS key makes every value of the profile unreadable (
KEY_STATE_INVALID) and, because every tokenize call computes fingerprints, stops tokenizing too until the key is enabled again. Deleting the key destroys the values for good. - Tokens are unique per profile. SQL adapters index fingerprints (migration
0006_protection_indexes), so deterministic lookups and erasure by value do not scan the tenant's tokens.
The audit trail records the profile, the number of values or tokens, how many were found or new, and the purpose. It
never records values or tokens. Refusals record the profile and the purpose too; a call refused after it was
authorized is recorded as a deny with metadata.reason key-unavailable (the key is disabled or pending deletion)
or refused. Uses of the KMS key are audited on the key with via: 'protection'.
afterOperation hooks never see values or tokens.
Retention
Run iam.protection.sweep() daily next to the other scheduler jobs. It deletes tokens
older than their profile's retentionDays, 500 per transaction so the write lock is never held for a whole profile,
audits each profile it deleted tokens from as protection:retention-sweep (actor deployment-operator, with the
number deleted), and returns { deleted, profiles }. Pass { tenantId } to limit it to one tenant.
setInterval(() => void iam.protection.sweep(), 24 * 60 * 60 * 1000);deleteProfile (iam:protection:manage and a recent sign-in) refuses a profile that still holds tokens. Delete its
tokens first, or let retention empty it.
Errors
| Code | Status | When |
|---|---|---|
INVALID_INPUT | 400 | A value that is not valid for the data type, a mask its type does not allow, a bad purpose |
KEY_STATE_INVALID | 409 | The profile's KMS key is disabled or pending deletion |
KEY_MANAGED | 409 | Binding a key another profile or module manages |
KEY_MATERIAL_UNAVAILABLE | 500 | A stored value failed authentication (altered in storage) |
RESOURCE_IN_USE | 409 | Deleting a profile that still holds tokens |
CONFLICT | 409 | A profile name in use, or no free format-preserving token for a value |
LIMIT_EXCEEDED | 409 | The tenant already has 100 profiles |
NOT_FOUND | 404 | No profile of that name in this tenant |
ACCESS_DENIED | 403 | The profile, the purpose, the mask style or the customer key is not allowed |
Next steps
Better IAM is created by Sean Filimon
Last updated
Secrets vaultnew
Versioned secrets with scheduled rotation, check-outs of shared credentials, dynamic per-caller credentials, and values under your own KMS keys.
Private CAnew
Per-tenant X.509 authorities on KMS keys for mutual TLS, internal HTTPS and SPIFFE workload identity, with every certified name decided by policy.