Environment variables remain the default way applications receive credentials, and they remain the first thing attackers and accidental logs grab. The pattern is seductive: one line in the Dockerfile, one line in the process manager, done. But an environment block has exactly the wrong properties for a secret — it is trivially readable by anything that can inspect your process, it is copied verbatim into every child process you spawn, and it is frozen at start time so rotation means redeploying. This post walks through where environment variables leak, what Kubernetes Secrets do and do not protect, and four concrete replacement patterns for 2026: file-based secret mounts, GitOps-friendly encryption with SOPS or Sealed Secrets, and pulling secrets on demand from a real secret manager like Vault.
Where Environment Variables Leak
The environment of a process is not an abstraction — it is a buffer in kernel memory exposed at a well-known path. Any process running as the same user can read /proc/PID/environ and get every secret your app holds. In a container that means any process inside the same container, including a debug shell you attach, a postinstall script from a compromised dependency, or a crash handler.
Outside the container, the leaks multiply:
kubectl get pod -o yamlandkubectl describerender environment variables for anyone with pod read access — no need for etcd or the API server’s secret subresource.docker inspectprints the environment of any container on the host, which is how secrets end up in shared CI build logs and screenshots.- Crash reporters and error trackers routinely capture the process environment alongside the stack trace, then ship it to a third party.
- Child processes inherit the full environment. Your Node app spawns a CLI tool, the CLI tool has a verbose mode that dumps env, and the database password leaves the machine.
- Config loaders and startup scripts echo values while debugging, and the output lands in logs.
None of these require exploiting your application. They are the normal behaviors of ordinary tooling.
Kubernetes Secrets: Base64 Is Not Encryption
The most persistent misconception in this space is that putting a credential in a Kubernetes Secret “secures” it. A Kubernetes Secret is an object whose data field holds base64-encoded strings — an encoding, not a cipher. Anyone who can read the object gets the plaintext. What Secrets do give you is a better blast radius than env vars baked into pod specs: the values are delivered via the Secrets API instead of living in your Git repository, and access is governed by RBAC, so you can restrict who reads them.
To make Secrets actually confidential at rest, the cluster needs encryption at rest: an EncryptionConfiguration for the API server with a real provider — aescbc, secretbox, or ideally kms v2 backed by a cloud key management service. Without it, Secrets sit in etcd as base64, and anyone with etcd access (a node compromise, an etcd backup on an unencrypted disk) has every credential in the cluster. If you manage your own cluster, check this setting today; many managed offerings enable it for you, but it is not the default for self-hosted clusters.
Two more Secret features are worth adopting. Immutable Secrets (immutable: true) can neither be updated nor — importantly for supply-chain blast radius — have their contents silently rewritten by a compromised service account with patch permissions. And remember the size limit: one mebibyte per Secret, which is a firm ceiling, not a suggestion.
Prefer Files Over Environment
If you do nothing else, switch from env to secretKeyRef-mounted volumes. A Secret mounted as a volume delivers each key as a file under a directory you choose, and the kubelet keeps files backed by Secrets synchronized — when the Secret changes, the files update, no restart required. Reading a file at startup also avoids the inheritance problem: spawning child processes does not propagate your file contents.
apiVersion: v1
kind: Pod
metadata:
name: payments-api
spec:
containers:
- name: server
image: registry.example.com/payments-api:1.42.0
volumeMounts:
- name: stripe-credentials
mountPath: /var/run/secrets/stripe
readOnly: true
volumes:
- name: stripe-credentials
secret:
secretName: stripe-api-key
The application reads the file instead of consulting its environment:
package main
import (
"fmt"
"log"
"os"
"strings"
)
func loadSecret(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("reading secret from %s: %w", path, err)
}
value := strings.TrimSpace(string(data))
if value == "" {
return "", fmt.Errorf("secret at %s is empty", path)
}
return value, nil
}
func main() {
apiKey, err := loadSecret("/var/run/secrets/stripe/api_key")
if err != nil {
log.Fatalf("startup failed: %v", err)
}
fmt.Println("loaded Stripe key of length", len(apiKey))
}
Fail loudly at startup if the file is missing — a service that boots with an empty credential and fails on first use is harder to debug than one that refuses to start.
Keeping Secrets Out of Git: SOPS and Sealed Secrets
GitOps workflows hit a wall with Secrets: you want declarative config in Git, but Git must never hold plaintext credentials. Two tools solve this with different trade-offs.
SOPS encrypts the values in a file while leaving the keys readable, so a diff still shows structure and reviewers can see which fields changed without seeing contents. It supports YAML, JSON, ENV, INI and binary formats, and encrypts with age, AWS KMS, GCP KMS, Azure Key Vault, HuaweiCloud KMS or PGP. The typical workflow: developers encrypt with an age key or a cloud KMS key, commit the ciphertext, and a CI step or in-cluster decryptor (Flux and Argo CD both support SOPS decryption) produces the live Secret. Who can decrypt is exactly the set of holders of the key — no cluster-side component needed.
Sealed Secrets inverts the trust model. A controller runs in the target cluster and holds the private key; you encrypt locally with its public key using kubeseal, and the resulting SealedSecret custom resource is safe to commit anywhere — only the controller in that specific cluster can decrypt it. The trade-off is the opposite of SOPS: decryption is bound to the cluster rather than to a set of key holders, and sealing is one-way, so edits require re-sealing from the original plaintext.
- Choose SOPS when you want decryption tied to people or CI identity, with KMS-backed audit trails.
- Choose Sealed Secrets when you want zero external dependencies and accept cluster-bound keys — and back up the controller’s key, or cluster rebuild means re-sealing everything.
Pulling From a Real Secret Manager
Static credentials have a second problem beyond storage: they never expire. Secret managers fix this with dynamic, leased credentials and a central audit log. In Kubernetes, two integration styles dominate. The External Secrets Operator syncs from external APIs — AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, Vault, and many others — into native Kubernetes Secrets, so your workloads keep consuming plain Secrets while the source of truth lives outside the cluster. Alternatively, applications talk to the secret manager directly, which is the better fit when you want dynamic credentials with short leases.
Direct integration with Vault removes long-lived credentials from the workload entirely: the pod authenticates with its service account token — a credential Kubernetes already mounted and rotates automatically — and receives a Vault token it never stores. Reading from the KV v2 engine then looks like this:
package main
import (
"context"
"fmt"
"log"
vault "github.com/hashicorp/vault/api"
k8sauth "github.com/hashicorp/vault/api/auth/kubernetes"
)
func main() {
client, err := vault.NewClient(vault.DefaultConfig())
if err != nil {
log.Fatalf("creating Vault client: %v", err)
}
auth, err := k8sauth.NewKubernetesAuth("payments-api")
if err != nil {
log.Fatalf("building Kubernetes auth method: %v", err)
}
if _, err := client.Auth().Login(context.Background(), auth); err != nil {
log.Fatalf("logging in to Vault: %v", err)
}
secret, err := client.KVv2("payments").Get(context.Background(), "stripe")
if err != nil {
log.Fatalf("reading secret: %v", err)
}
apiKey, ok := secret.Data["api_key"].(string)
if !ok {
log.Fatal("api_key missing or not a string")
}
fmt.Println("loaded Stripe key of length", len(apiKey))
}
The service account never leaves the pod, nothing lands in the environment, and Vault’s audit devices record every read. The Kubernetes auth method docs cover binding a Vault role to a service account, and the KV v2 engine adds versioning and soft deletion on top of simple key-value storage.
Choosing a Setup
- Baseline for any cluster: etcd encryption at rest, RBAC on Secrets, and volumes instead of env vars in application manifests.
- GitOps with a few clusters and no external secret store: SOPS with age or a cloud KMS key.
- GitOps that must be cluster-portable and dependency-free: Sealed Secrets, with the controller key backed up.
- Dynamic credentials, leasing, and auditability at scale: Vault (or a cloud secret manager) with the External Secrets Operator for static values and direct auth for dynamic ones.
Environment variables were never designed to hold credentials, and every year the tooling around them gets better at exposing them. The migration path does not have to happen all at once: switch mounts first, add etcd encryption second, and move your most sensitive credentials to a leased source last. Each step removes an entire class of leak without changing how your application is deployed.