Secrets in Cloud-Init and VM Startup Scripts: The Credentials Baked Into Your Infrastructure
July 14, 2026
The Startup Script Nobody Audits
When an engineer provisions a new EC2 instance, GCP VM, or Azure virtual machine, the fastest way to get it configured is a startup script: a few lines of shell passed in as user-data or a cloud-init YAML block. Within minutes the instance is bootstrapped, configured, and running. The startup script is promptly forgotten.
But the script isn't gone. It's stored in your cloud provider's instance metadata, often in your IaC repository, sometimes in a golden AMI or machine image, and occasionally in a third-party configuration management system. Every copy is a potential exposure vector—and startup scripts are disproportionately likely to contain hardcoded credentials, because they're written under pressure, at provisioning time, by engineers who are thinking about infrastructure, not secret hygiene.
Where Credentials Typically Appear in Startup Scripts
The pattern is almost always the same: a shell variable or inline flag that a developer meant to replace later but never did. Here are the most common forms:
Environment variable exports
#!/bin/bash
export DATABASE_URL="postgres://admin:hunter2@prod-db.internal:5432/app"
export STRIPE_SECRET_KEY="sk_live_xxxxxxxxxxxxxxxxxxxxxxxx"
systemctl start myapp
Exporting secrets in shell this way embeds them in the script text itself, not just in the process environment. Anyone who can read the script—or retrieve the instance's user-data via the metadata endpoint—gets the credentials.
curl and wget calls with Authorization headers
curl -H "Authorization: Bearer ghp_xxxxxxxxxxxxxxxx" \
https://api.github.com/repos/org/private-repo/tarball/main \
-o /tmp/app.tar.gz
This is especially dangerous because the token is visible in both the script text and in the process list (ps aux) while the command runs.
Package manager and pip configurations written inline
pip install --extra-index-url \
https://oauth2:glpat-xxxx@gitlab.com/api/v4/projects/123/packages/pypi/simple \
mypackage
Private registry tokens embedded in package index URLs end up in shell history, pip logs, and the script itself.
Cloud-init YAML write_files blocks
#cloud-config
write_files:
- path: /etc/app/config.yaml
content: |
db_password: "supersecret"
api_key: "sk_live_xxxx"
YAML cloud-init configs are even easier to miss during a repo audit because they don't look like code—they look like configuration. Automated scanners often skip them if they're not explicitly included in scan scope.
Why These Credentials Persist Longer Than You Expect
Instance metadata is queryable from inside the VM
On AWS, any process running on the instance can retrieve the original user-data script with a single unauthenticated request to http://169.254.169.254/latest/user-data. This includes malicious code introduced via a dependency, a compromised container, or an SSRF vulnerability in your application. If you hardcoded a credential in user-data, you've effectively made it available to every process on that host for the lifetime of the instance.
IaC repositories preserve the full history
Startup scripts embedded in Terraform user_data blocks, Pulumi programs, or CloudFormation UserData properties are committed to version control. Even if you later move the credential to a secrets manager, the old value lives in git history indefinitely unless you explicitly purge it.
Machine images bake in the script
If a VM was started from a startup script, then snapshotted into a golden AMI or Compute Engine image for faster provisioning, the credentials in that script may be baked into every instance launched from that image going forward—even years after the script was "removed" from the provisioning workflow.
How to Find Exposed Credentials in Startup Scripts Right Now
Auditing startup scripts requires looking in more places than a standard repo scan:
- Search your IaC code for
user_data,UserData,metadata_startup_script, andcustomDatablocks. These are the fields where startup scripts are embedded in Terraform, CloudFormation, Pulumi, and ARM templates respectively. - Query live instances using your cloud provider's CLI. On AWS:
aws ec2 describe-instance-attribute --instance-id i-xxxx --attribute userData --query 'UserData.Value' --output text | base64 --decode. Pipe the output through a pattern matcher for common secret formats. - Check your Packer templates and any CI/CD pipelines that build machine images.
provisioner "shell"blocks in Packer are a frequent hiding spot. - Audit Ansible, Chef, and Puppet roles that are called during instance initialization—especially any
templateorcopytasks that write config files with inline values. - Review git history for the IaC repos, not just the current HEAD. A credential removed from a Terraform file three months ago is still in the commit log.
For a faster starting point, run a free GhostCred scan against your repository—it will flag credential patterns in IaC files, shell scripts, and YAML configs, including inside multi-line embedded script blocks that manual grep often misses.
The Right Architecture: Startup Scripts Should Never Contain Secrets
The fix is architectural, not cosmetic. The goal is to have startup scripts that contain references to secrets, not the secrets themselves.
Use IAM instance roles to fetch secrets at runtime
The cleanest pattern on AWS is to assign an IAM instance profile with read access to specific AWS Secrets Manager or SSM Parameter Store paths, then fetch the secret inside the startup script:
#!/bin/bash
DB_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id prod/myapp/db_password \
--query SecretString \
--output text)
export DATABASE_URL="postgres://admin:${DB_PASSWORD}@prod-db.internal:5432/app"
The script itself contains no credential. The instance's IAM role provides access, and the role can be scoped to exactly the secrets it needs. The same pattern exists on GCP (Workload Identity + Secret Manager) and Azure (Managed Identity + Key Vault).
Limit IAM role scope aggressively
An instance role that can read any secret in Secrets Manager is nearly as dangerous as a hardcoded credential. Scope the role's secretsmanager:GetSecretValue permission to the specific secret ARNs the instance actually needs, and nothing else.
Disable or obfuscate user-data after provisioning
AWS allows you to clear or replace instance user-data after launch: aws ec2 modify-instance-attribute --instance-id i-xxxx --user-data Value="". If your startup script was a one-time bootstrap, clear it once the instance is running so it's no longer queryable from inside the VM.
Scan IaC in CI, not just the application repo
Many teams run secret scanning on their application code but not on their infrastructure repositories. Your Terraform modules, Ansible playbooks, and Packer templates deserve the same treatment. Add a scan step to your IaC pipeline that fails the build if a credential pattern is detected in a user_data block or startup script.
Compliance Implications
Under SOC 2 CC6.1 (logical access controls) and HIPAA §164.312(a)(2)(iv) (encryption and integrity controls), hardcoded credentials in infrastructure code represent a direct control failure. Auditors increasingly ask for evidence of automated secret scanning coverage—and "we scan our application code" is not sufficient if your IaC repositories are excluded. The exposure surface in startup scripts is particularly difficult to explain to an auditor because it can persist for years in both git history and live instance metadata simultaneously.
Quick Reference: Startup Script Secret Checklist
- No raw credentials in
user_data,UserData,metadata_startup_script, orcustomDatafields - No
export SECRET=lines in bootstrap shell scripts - No credentials in curl/wget
-Hflags or package index URLs - No inline secret values in
write_filescloud-init blocks - All instance roles scoped to specific secret ARNs, not wildcard paths
- IaC repos included in secret scanning CI pipeline
- Git history of IaC repos audited for historical credential commits
- Live instances queried and audited for user-data secrets
- Machine images (AMIs, Compute Engine images) traced back to their source scripts
Startup scripts are infrastructure glue—written fast, rarely reviewed, and almost never included in the scope of a security audit. That's exactly what makes them dangerous. Treat them as code, scan them as code, and keep secrets out of them entirely.
See what's exposed in your own code.
Run a free scan