This check flags Azure VMs with the customData field set, which is often used to pass startup scripts that contain hardcoded passwords, API keys, or tokens. Anyone with read access to the VM config can decode and read it, so move secrets into Key Vault and reference them at boot instead.
The customData property on an Azure virtual machine is a convenient way to hand a script or config blob to the VM at provisioning time. The cloud-init agent (Linux) or the Azure VM Agent (Windows) picks it up on first boot and runs it. The problem is that teams routinely use it to bootstrap machines with database connection strings, registry credentials, or service account tokens baked directly into the script. Once that data is in customData, it stays there for the life of the VM and is readable by anyone who can describe the instance.
This Lensix check, vm_secretsinuserdata, looks for Azure VMs where customData is populated and surfaces them so you can verify whether secrets are leaking through it.
What this check detects
The check inspects the osProfile.customData field of each Azure VM in scope. If the field is set (non-empty), the VM is flagged. Lensix does not assume the data is malicious, it flags the presence of the field because that is where plaintext secrets most commonly end up on Azure compute.
Custom data is supplied as a base64-encoded string at deployment. Azure decodes it and writes it to disk on the VM:
- Linux:
/var/lib/cloud/instance/user-data.txt(or processed by cloud-init) - Windows:
%SYSTEMDRIVE%\AzureData\CustomData.bin
Note: Azure has two related fields. customData is delivered once at provisioning and is base64-encoded but not encrypted. userData is a newer field retrievable from the Instance Metadata Service at runtime. Neither is a safe place for secrets, since both are visible to anyone with the right read permissions.
Why it matters
Base64 is encoding, not encryption. A reader who retrieves the field runs one command to get back the original text. The risk is not theoretical:
- Broad read access. Any principal with
Microsoft.Compute/virtualMachines/readcan pull the VM config, includingcustomData. That permission is common in operations, monitoring, and reader roles that you would not normally consider sensitive. - On-disk persistence. The decoded data is written to the VM filesystem. A compromised low-privilege account on the box, or a stolen disk snapshot, exposes everything in it.
- Snapshot and image sprawl. If a VM with secrets in custom data is captured into a managed image or shared image gallery, the secret propagates to every VM built from that image.
- Long-lived exposure. Unlike a build-time secret that disappears after a pipeline run, custom data lives with the VM for its entire lifecycle and rarely gets rotated.
A realistic attack chain: an attacker phishes a credential for an account with reader-level access to a subscription. They enumerate VMs, dump customData across the fleet, decode it, and find a database admin password that was used to bootstrap an app server two years ago. Because the password was never rotated, they pivot straight to the database. No exploit required, just a config field that should never have held a secret.
Danger: Treat any secret found in customData as already compromised. Rotate it immediately rather than just removing it from the field. Removing the value does not undo the exposure from every snapshot, log, or backup it may have landed in.
How to inspect what is actually there
Before changing anything, confirm whether the flagged VMs really contain secrets. Pull the field and decode it.
# List VMs that have customData set
az vm list \
--query "[?osProfile.customData!=null].{name:name, rg:resourceGroup}" \
-o table
# Retrieve and decode customData for one VM
az vm show \
--resource-group my-rg \
--name my-vm \
--query "osProfile.customData" \
-o tsv | base64 --decode
Read through the decoded output. If you see connection strings, passwords, tokens, or private keys, you have confirmed the finding.
Warning: Azure does not always return customData through the API after provisioning for older VM configurations. If the API returns null but you suspect secrets, check the on-disk locations directly on the VM (/var/lib/cloud/instance/user-data.txt or C:\AzureData\CustomData.bin).
How to fix it
The fix has two phases: get the secret out of harm's way, then change how the VM gets its configuration going forward.
1. Rotate every exposed secret
Whatever credential was in the custom data needs to be revoked and reissued. Do this first, because the remaining steps do not protect against a secret that has already leaked.
2. Move secrets into Azure Key Vault
Store the values in Key Vault and give the VM a managed identity to read them at runtime instead of baking them into the bootstrap.
# Create or reuse a Key Vault
az keyvault create \
--name my-app-kv \
--resource-group my-rg \
--location eastus
# Store the rotated secret
az keyvault secret set \
--vault-name my-app-kv \
--name db-connection-string \
--value "Server=...;Password=NEW_ROTATED_VALUE"
# Assign a system-assigned managed identity to the VM
az vm identity assign \
--resource-group my-rg \
--name my-vm
# Grant that identity read access to the secret
az keyvault set-policy \
--name my-app-kv \
--object-id $(az vm show -g my-rg -n my-vm --query identity.principalId -o tsv) \
--secret-permissions get list
Your bootstrap script then fetches the secret at runtime using the managed identity, so nothing sensitive lives in customData:
#!/bin/bash
# Fetch a token for Key Vault using the VM's managed identity
TOKEN=$(curl -s -H "Metadata: true" \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fvault.azure.net" \
| jq -r '.access_token')
# Pull the secret
DB_CONN=$(curl -s -H "Authorization: Bearer $TOKEN" \
"https://my-app-kv.vault.azure.net/secrets/db-connection-string?api-version=7.4" \
| jq -r '.value')
export DB_CONN
3. Rebuild or update the VM without the secret
You cannot change customData on a running VM, it is only honored at provisioning. To remove a leaked value you generally need to redeploy. Update your IaC to drop the secret from custom data, then recreate the VM.
Here is the pattern in Terraform, custom data carries only non-sensitive bootstrap logic:
resource "azurerm_linux_virtual_machine" "app" {
name = "my-vm"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
size = "Standard_B2s"
admin_username = "azureuser"
identity {
type = "SystemAssigned"
}
# No secrets here. Just logic that fetches secrets at runtime.
custom_data = base64encode(file("${path.module}/cloud-init.sh"))
# ... network_interface_ids, os_disk, source_image_reference ...
}
Tip: If you only need to pass an SSH public key, use the dedicated admin_ssh_key block (or --ssh-key-values in the CLI) rather than stuffing keys into custom data. Public keys are not secrets, but keeping config in the right field makes audits cleaner.
How to prevent it from happening again
Manual cleanup does not scale. Catch this in the pipeline and enforce it with policy.
Block it with Azure Policy
You can deny VM deployments that include custom data, or audit them, with a policy definition:
{
"if": {
"allOf": [
{
"field": "type",
"equals": "Microsoft.Compute/virtualMachines"
},
{
"field": "Microsoft.Compute/virtualMachines/osProfile.customData",
"exists": "true"
}
]
},
"then": {
"effect": "audit"
}
}
Start with audit to measure the blast radius across your subscriptions, then switch to deny for environments where you have confirmed no legitimate use depends on raw custom data.
Scan IaC before it merges
Static analysis tools like Checkov, tfsec, and Terrascan can flag suspicious custom data in pull requests. Pair them with a secret scanner so a hardcoded password in a cloud-init file fails the build:
# Run in CI before terraform apply
checkov -d . --framework terraform
# Catch raw secrets in any tracked file
gitleaks detect --source . --no-banner
Tip: Wire Lensix into your alerting so the vm_secretsinuserdata check runs continuously against your live environment. Policy and CI gates catch new deployments, but continuous scanning catches drift and resources created outside your pipelines.
Best practices
- Never put secrets in any metadata field.
customData,userData, tags, and resource names are all readable by more principals than you expect. Secrets belong in Key Vault. - Use managed identities. They remove the need to distribute credentials to VMs at all. The VM authenticates to Azure services with an identity Azure manages and rotates.
- Keep bootstrap scripts declarative and secret-free. Custom data should describe how to fetch configuration, not contain the configuration itself.
- Rotate on exposure, not on suspicion. If a secret has touched a metadata field, assume it is burned and rotate it.
- Apply least privilege to read roles. Reduce who can call
virtualMachines/read, since that permission is enough to harvest custom data fleet-wide. - Audit images and snapshots too. A clean VM built from a dirty image inherits the problem. Scan your shared image gallery as part of the same review.
Custom data is a useful provisioning tool, and the goal is not to ban it. The goal is to make sure it only ever holds instructions, never the secrets those instructions need. Once you route credentials through Key Vault and managed identities, this whole class of finding goes away.

