Vault Transform Engine: Format-Preserving Encryption for Sensitive Data on Nomad
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

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 | Transform (FPE) | Transform (Masking) | |
|---|---|---|---|
| Output looks like input | ✗ | ✓ | ✗ |
| Reversible | ✓ | ✓ | ✗ |
| Use case | General-purpose encryption | Regulated data (SSN, PAN) with format constraints | One-way obfuscation (logs, display) |
| Vault engine | transit | transform | transform |
In our demo application we use all three:
birth_date, address, salary (general fields, ciphertext is fine)ssn (social security number, must stay in NNN-NN-NNNN format)ccn (credit card number, shown as XXXX-XXXX-XXXX-1234 in display views)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
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
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
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.
db_client_transform.pyThe 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.
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.
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.
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.
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
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}
After inserting a customer record, here is what the raw database rows look like compared to what the application renders:
| Field | Raw DB value | Rendered (app view) |
|---|---|---|
birth_date | vault:v1:AbC123... | 1985-04-12 |
ssn | 987-65-4321 | 123-45-6789 |
ccn | XXXX-XXXX-XXXX-1234 | XXXX-XXXX-XXXX-1234 |
address | vault:v1:XyZ456... | 42 Main Street |
salary | vault: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.
The Vault Transform engine fills an important gap in the secrets management toolkit:
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.
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