Vault Transform Engine: Format-Preserving Encryption for Sensitive Data on Nomad


Bicycle

In our previous post about HashiCorp Nomad and Vault: Dynamic Secrets we walked through the full lifecycle of secrets management for a Python Flask application running on Nomad — from hardcoded credentials all the way to Vault Transit encryption. Transit is powerful: it turns a credit card number into vault:v1:abc123... — unrecognisable ciphertext that is impossible to misuse.

But that strength is sometimes a problem.

Consider a legacy system that validates the format of a social security number before storing it, or a data warehouse that expects credit card numbers to still look like credit card numbers even after encryption. Replacing the value with opaque ciphertext breaks those systems. You need format-preserving encryption (FPE) — and that is exactly what the Vault Transform secret engine provides.

Transit vs Transform vs Masking

TransitTransform (FPE)Transform (Masking)
Output looks like input
Reversible
Use caseGeneral-purpose encryptionRegulated data (SSN, PAN) with format constraintsOne-way obfuscation (logs, display)
Vault enginetransittransformtransform

In our demo application we use all three:

  • Transitbirth_date, address, salary (general fields, ciphertext is fine)
  • Transform FPEssn (social security number, must stay in NNN-NN-NNNN format)
  • Transform Maskingccn (credit card number, shown as XXXX-XXXX-XXXX-1234 in display views)

Setting Up the Transform Engine

The Vault Transform engine is separate from the Transit engine. Enable it at a dedicated mount point.

Note: Transform is a Vault Enterprise feature (also available on HCP Vault Dedicated).

1# Enable the Transform secret engine
2vault secrets enable -path=dynamic-app/transform transform
3
4# Enable a second mount for masking (keeps policies cleanly separated)
5vault secrets enable -path=dynamic-app/transform-masking transform

SSN — Format-Preserving Encryption

FPE for SSNs uses Vault's built-in builtin/socialsecuritynumber template, which enforces the NNN-NN-NNNN pattern. The encrypted value is also a valid-looking SSN — downstream format validators pass without any changes.

 1# Create the FPE transformation for SSN
 2vault write dynamic-app/transform/transformation/ssn \
 3    type=fpe \
 4    template=builtin/socialsecuritynumber \
 5    tweak_source=internal \
 6    allowed_roles=ssn
 7
 8# Create the role
 9vault write dynamic-app/transform/role/ssn \
10    transformations=ssn

CCN — Masking

Credit card numbers are masked rather than FPE-encrypted. The result is irreversible (XXXX-XXXX-XXXX-1234) which is appropriate for display in logs, audit trails, and read-only views.

1# Create the masking transformation for CCN
2vault write dynamic-app/transform-masking/transformation/ccn \
3    type=masking \
4    masking_character=X \
5    template=builtin/creditcardnumber \
6    allowed_roles=ccn
7
8vault write dynamic-app/transform-masking/role/ccn \
9    transformations=ccn

Vault Policy

Extend the existing nomad-dynamic-app policy to permit encoding and decoding on the new paths:

 1# Existing transit permissions (abbreviated)
 2path "dynamic-app/transit/encrypt/app" {
 3  capabilities = ["create", "update"]
 4}
 5path "dynamic-app/transit/decrypt/app" {
 6  capabilities = ["create", "update"]
 7}
 8
 9# New transform permissions
10path "dynamic-app/transform/encode/ssn" {
11  capabilities = ["create", "update"]
12}
13path "dynamic-app/transform/decode/ssn" {
14  capabilities = ["create", "update"]
15}
16path "dynamic-app/transform-masking/encode/ccn" {
17  capabilities = ["create", "update"]
18}

Note: masking is one-way, so no decode permission is needed for the CCN path.

The Python Application — db_client_transform.py

The DbClient in db_client_transform.py extends the Transit client. It inherits all existing Transit encrypt/decrypt behaviour for birth_date, address, and salary, and adds SSN and CCN handling on top.

The Vault Python SDK (hvac) includes Transform API support. In this implementation, we still use hvac's underlying HTTP adapter to call the Transform endpoints directly so we can keep request handling explicit and aligned across language variants. This integrates cleanly with hvac's session management and SSL configuration.

Initialising the Transform Client

 1def init_transform(
 2    self,
 3    transform_path,
 4    transform_masking_path,
 5    ssn_role,
 6    ccn_role,
 7):
 8    self.transform_mount_point = transform_path
 9    self.transform_masking_mount_point = transform_masking_path
10    self.ssn_role = ssn_role
11    self.ccn_role = ccn_role
12    logger.debug(f"Initialized transform: {self.vault_client}")

The transform_mount_point and transform_masking_mount_point are read from config.ini so the app is fully configuration-driven without any hardcoded paths.

Encoding an SSN

 1def encode_ssn(self, value):
 2    try:
 3        url = (
 4            self.vault_client.url
 5            + "/v1/"
 6            + self.transform_mount_point
 7            + "/encode/"
 8            + self.ssn_role
 9        )
10        headers = {
11            "X-Vault-Token": self.vault_client.token,
12            "X-Vault-Namespace": super().get_namespace(),
13            "Content-Type": "application/json",
14            "cache-control": "no-cache",
15        }
16        # Use hvac's adapter for HTTP calls
17        response = self.vault_client.adapter.post(
18            url=url,
19            json={"value": value, "transformation": self.ssn_role},
20            headers=headers,
21            timeout=300,
22        )
23        logger.debug(f"Response: {response.text}")
24        return response.json()["data"]["encoded_value"]
25    except Exception as e:
26        logger.error(f"There was an error encrypting the data: {e}")
27    return ""

The input 123-45-6789 comes back as something like 987-65-4321 — numerically encrypted, structurally identical. Notice we use self.vault_client.adapter.post() with json={} for a cleaner payload construction than raw string building.

Decoding an SSN

 1def decode_ssn(self, value):
 2    logger.debug(f"Decoding {value}")
 3    try:
 4        url = (
 5            self.vault_client.url
 6            + "/v1/"
 7            + self.transform_mount_point
 8            + "/decode/"
 9            + self.ssn_role
10        )
11        headers = {
12            "X-Vault-Token": self.vault_client.token,
13            "X-Vault-Namespace": self.get_namespace(),
14            "Content-Type": "application/json",
15            "cache-control": "no-cache",
16        }
17        response = self.vault_client.adapter.post(
18            url=url,
19            json={"value": value, "transformation": self.ssn_role},
20            headers=headers,
21            timeout=300,
22        )
23        logger.debug(f"Response: {response.text}")
24        return response.json()["data"]["decoded_value"]
25    except Exception as e:
26        logger.error(f"There was an error decoding the data: {e}")
27    return None

Decoding is the exact reverse. Only applications that hold the correct Vault token and policy can retrieve the original value.

Processing a Customer Record

The process_customer method selectively applies decryption based on field type — Transit for birth_date, address, and salary; Transform decode for ssn; CCN is never decoded (masking is irreversible):

1def process_customer(self, row, raw=None):
2    r = { ... }
3    if self.vault_client is not None and not raw:
4        r["birth_date"] = self.decrypt(r["birth_date"])       # Transit
5        r["ssn"]        = self.decode_ssn(r["ssn"])           # Transform FPE
6        r["address"]    = self.decrypt(r["address"])          # Transit
7        r["salary"]     = self.decrypt(r["salary"])           # Transit
8        # ccn stays masked — no decode
9    return r

Nomad Job Configuration

The Nomad job file adds the Transform configuration section to the rendered config.ini. No changes are required to the vault stanza — the existing nomad-dynamic-app policy already covers the new paths added above.

 1task "dynamic-app" {
 2  driver = "docker"
 3
 4  config {
 5    image = "quay.io/infralovers/nomad-vault-mysql"
 6    volumes = ["local/config.ini:/usr/src/app/config/config.ini"]
 7    ports   = ["web"]
 8  }
 9
10  template {
11    destination = "local/config.ini"
12    data        = <<EOF
13[DEFAULT]
14LogLevel = DEBUG
15Port = 8080
16
17[DATABASE]
18{{ range service "mysql-server" }}
19Address = {{ .Address }}
20Port    = {{ .Port }}
21{{ end }}
22{{ with secret "dynamic-app/kv/database" }}
23Database = {{ .Data.data.database }}
24{{ end }}
25{{ with secret "dynamic-app/db/creds/app" }}
26User     = {{ .Data.username }}
27Password = {{ .Data.password }}
28{{ end }}
29
30[VAULT]
31Enabled    = True
32InjectToken = True
33Namespace  =
34Address    = {{ env "VAULT_ADDR" }}
35KeyPath    = dynamic-app/transit
36KeyName    = app
37
38[VAULT_TRANSFORM]
39Enabled             = True
40TransformPath       = dynamic-app/transform
41TransformMaskingPath = dynamic-app/transform-masking
42SsnRole             = ssn
43CcnRole             = ccn
44EOF
45  }
46}

What the Data Looks Like

After inserting a customer record, here is what the raw database rows look like compared to what the application renders:

FieldRaw DB valueRendered (app view)
birth_datevault:v1:AbC123...1985-04-12
ssn987-65-4321123-45-6789
ccnXXXX-XXXX-XXXX-1234XXXX-XXXX-XXXX-1234
addressvault:v1:XyZ456...42 Main Street
salaryvault:v1:PqR789...85000

The SSN column looks like a valid social security number in the database — it passes format validators, can be indexed, and is only recoverable through Vault by an authorised application.

Summary

The Vault Transform engine fills an important gap in the secrets management toolkit:

  • Use Transit when you only care about confidentiality and the downstream system does not care about data shape.
  • Use Transform FPE when the encrypted value must still look like the original type — SSNs, credit card PANs, tax IDs.
  • Use Transform Masking when the value should never be recoverable — audit logs, read-only display fields.

All three operate from the same Nomad + Consul template pattern, requiring only a small extension to the config.ini and an updated Vault policy. The application code change is minimal and isolated to db_client_transform.py.

The full source code is available at github.com/infralovers/nomad-vault-mysql.

Go Back explore our courses

We are here for you

You are interested in our courses or you simply have a question that needs answering? You can contact us at anytime! We will do our best to answer all your questions.

Contact us