Secrets Management: Stop Putting Secrets in Code
API keys, database passwords, and JWT secrets become time bombs when hard-coded. This guide covers .env files for development, environment variables for Docker, production services such as HashiCorp Vault and AWS Secrets Manager, rotation and auditing, and incident response after a leak.
Key Points
- Topic Focus: DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
1. The Disaster of Hard-Coded Secrets
Tens of thousands of newly leaked secrets appear in public GitHub repositories every day. A new employee pushes company code to a personal public repository "as a backup," someone carelessly shares a screenshot of git log in a group chat, or an unredacted CI log prints a secret directly. Any one of these incidents can send an AWS bill into five figures overnight, expose an entire database, or let someone abuse an OpenAI key at scale.
These are not rare events. GitKraken, Codecov, Samsung, and Toyota have all made headlines over secret leaks. The real disaster is not the leak itself, but that the secrets are bound to the code: once the repository leaks, every secret leaks with it. Because Git preserves history, deleting the line does not help; the secret remains in every commit, every tag, and every fork.
The first principle, and the only nonnegotiable one, is this: secrets must be separated from code. Code repositories are meant to be read by people and run by CI, and they can be forked or mirrored. They are inherently public, even if they are private today. Treating secrets as first-class assets and removing them from the code is the prerequisite for every solution that follows.
Below, we will examine three layers, "development → containers → production," and explain what to use and what to avoid at each layer.
2. Layer One: .env + .gitignore (Enough for Development, Not for Production)
The most practical solution for local development is a .env file plus dotenv. It is simple, universal, and supported by libraries in every language.
# .env - always add it to .gitignore
DATABASE_URL=postgresql://localhost:5432/devdb
JWT_SECRET=dev-only-not-for-production
STRIPE_SECRET_KEY=sk_test_xxx
# .gitignore
.env
.env.local
.env.*.local
However, .env has two pitfalls that must be addressed:
First, commit a .env.example file as a template. It should clearly list the variables the project requires and their formats, but contain only placeholders, never real values. After cloning the repository, a new team member can run cp .env.example .env and immediately see what needs to be filled in. This is essential for collaboration, not optional.
# .env.example - a template tracked in version control
DATABASE_URL=postgresql://user:pass@host:5432/db
JWT_SECRET=
STRIPE_SECRET_KEY=
Second, .env must remain limited to development. Taking .env into production means storing production secrets in plaintext on the deployment machine's filesystem: anyone who logs in can run cat, a compromised machine exposes every secret, and there is no way to audit who accessed them or rotate them properly. For production, jump directly to layers three and four.
Also, after adding .env to .gitignore, make it a habit to glance at git status and confirm that .env does not appear among the files waiting to be committed. Add another layer of protection by using git-secrets or trufflehog for pre-commit scanning, so a secret is blocked before it actually enters the repository.
3. Layer Two: Environment Variables (Docker / K8s Secret)
In containerized deployments, secrets move from files to environment variables. The principle remains unchanged: secrets must not enter the image.
A Docker image is an artifact that can be pulled, cached, and distributed freely. Writing ENV DATABASE_URL=... in a Dockerfile packages the production secret into something that will be pushed to a registry and pulled onto developers' machines. The correct approach is to use placeholders at build time and inject real values at runtime:
# Dockerfile - declare the variable without assigning a real value
ENV DATABASE_URL=""
CMD ["sh", "-c", "node server.js"]
# Inject values at runtime with -e or --env-file
docker run -e DATABASE_URL=$DATABASE_URL -e JWT_SECRET=$JWT_SECRET myapp:latest
In K8s, store sensitive data in a Secret, not a ConfigMap, and provide it to the Pod through environment variables or mounted files:
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
DATABASE_URL: postgresql://prod-db:5432/app
JWT_SECRET: <inject externally; do not put it in YAML>
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
envFrom:
- secretRef:
name: app-secrets
Note that K8s Secrets are base64-encoded by default, not encrypted. Anyone who can run kubectl get secret -o yaml can decode them. In production, always enable encryption at rest for etcd and apply RBAC restrictions so that only specific ServiceAccounts can read specific Secrets. Alternatively, move to the next layer and mount secrets from an external secrets management service through a CSI Driver, keeping them out of the cluster's etcd entirely.
The same rule applies to CI/CD: store secrets in GitHub Actions Secrets or GitLab CI Variables, reference them in workflows through ${{ secrets.X }}, and inject them into environment variables during the build. Never write them into repository YAML files.
4. Layer Three: Secrets Management Services (Vault / Secrets Manager / Doppler)
What production environments should actually use is a dedicated secrets management service. It provides four core capabilities: centralized storage, dynamic retrieval, auditability, and rotation. Leading options include:
- HashiCorp Vault - Open source, self-hostable, and the most powerful option. It supports dynamic secrets, such as temporary database accounts created on demand, along with a rich selection of secret engines and authentication backends.
- AWS Secrets Manager / Parameter Store - Managed services with deep IAM, EC2, ECS, and Lambda integration, billed by stored secrets and API calls.
- Doppler / 1Password Secrets Automation - The best developer experience, with unified management across environments such as dev, staging, and prod, plus one-command retrieval.
With Vault, for example, an application starts without holding any long-lived secret. Instead, it authenticates to Vault using a machine identity such as a K8s ServiceAccount, AWS IAM, or GCP IAM, receives a short-lived token, and retrieves secrets as needed:
# On the application host, log in to Vault with a K8s identity
vault write auth/kubernetes/login role=my-app \
jwt=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
# Read database credentials - Vault creates a temporary account in the database on demand
vault read database/creds/my-app-role
# => username: v-root-my-app-abc123 password: ... lease_duration: 1h
These dynamic secrets are Vault's defining feature: the secrets do not exist in advance. They are generated on demand, destroyed after use, and come with built-in expiration. Even if one is stolen, it becomes worthless after an hour. This is the most complete implementation of the rotation principle.
If Vault is not currently an option, AWS Secrets Manager is still vastly better than hard-coding. Application code contains only an SDK call, while the real value lives in AWS with versioning, rotation, and CloudTrail auditing:
import boto3
client = boto3.client("secretsmanager")
resp = client.get_secret_value(SecretId="prod/myapp/db")
db_url = resp["SecretString"]
5. Rotation: Do Not Use One Secret Forever
Secrets have a finite lifetime. If a database password remains unchanged for three years, any developer, operator, contractor, or former employee who handled it during that period could create an irreversible problem. Rotation is the second pillar of secrets management, just as important as separation.
There are two types of rotation:
- Scheduled rotation: Replace secrets every 30 or 90 days, even when nothing has gone wrong. Make rotation part of the process instead of waiting for an incident.
- Event-driven rotation: When someone leaves, a commit looks suspicious, or an unauthorized-access alert fires, rotate the relevant secrets immediately. Do not wait.
Manual rotation is an anti-pattern: who will remember it, who will perform it, and what might be missed? Automation is the correct solution:
- Vault dynamic secrets: rotate naturally on an hourly basis without manual intervention.
- AWS Secrets Manager: provides built-in Lambda rotation functions that enable scheduled rotation with one action.
- Database passwords: use a blue-green dual-account strategy by maintaining
user_v1anduser_v2simultaneously, switching the connection string, and then disabling the old account. This avoids service interruptions during rotation.
The quickest way to gauge a team's maturity in secrets management is to ask, "How long would it take to rotate a database password right now?" If the answer is "one day, with downtime," the team is still at an early stage. "Five minutes, zero downtime, fully automated" is the minimum acceptable level.
6. Auditing and Least Privilege
The third core capability of a secrets management service is auditing: who retrieved which secret, and when. Vault enables audit logs by default, writing every read and write request to disk as JSON for consumption by a SIEM such as Splunk or Elastic Security. Every AWS Secrets Manager access event is recorded in CloudTrail.
# Vault audit log example (excerpt)
{"type":"request","path":"secret/data/prod/db","auth":{"display_name":"my-app"}}
{"type":"response","path":"secret/data/prod/db","operation":"read"}
Auditing depends on least privilege. Give each service access only to the specific secrets it actually needs instead of granting a wildcard for convenience. In Vault, policies provide precise control:
# The my-app service can read only prod/db and prod/stripe; everything else is denied
path "secret/data/prod/db" { capabilities = ["read"] }
path "secret/data/prod/stripe" { capabilities = ["read"] }
path "secret/data/*" { capabilities = ["deny"] }
Least privilege is not only a defense against external attackers. It also protects against insider threats: the intern who was phished, the veteran employee about to leave, or the compromised edge node. The blast radius should always be as small as possible.
7. The Illusion of "Frontend Secrets"
In one sentence: there is no such thing as a "frontend secret". Users can inspect everything in the browser, including the JS bundle, network requests, localStorage, and environment variables. Pressing F12 exposes it all.
Next.js's NEXT_PUBLIC_ prefix is often misunderstood. It does not mean "secure public configuration." It explicitly says, "This will be bundled into client-side code, so it is not a secret in the first place."
# This is bundled into the client and visible to users - only public configuration belongs here
NEXT_PUBLIC_GA_ID=G-XXXXXXX
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
# This remains on the server and is not exposed - real secrets belong here
STRIPE_SECRET_KEY=sk_live_xxx
DATABASE_URL=...
The test is simple: would misuse of this value cause damage? A Google Analytics ID or public key would not, so it can be placed in the frontend. A key that can spend money, credentials that can query a database, or a secret that can sign JWTs must remain on the server. Every operation requiring authorization should have the frontend call your backend API, which then calls the third party with the secret. The frontend should hold only an "identity identifier," never an "authorization credential."
8. Incident Response: filter-repo + Immediate Rotation
If you discover that a secret entered the repository, respond in this order. Do not start by deleting the line. That accomplishes nothing because the secret remains in Git history.
First, immediately rotate every secret that may have leaked. This takes priority over cleaning the history because attackers can use the secrets for as long as they remain valid. Replace every database password, API key, JWT secret, and SSH key, and revoke the old credentials at the same time.
Second, clean the Git history. Use git filter-repo, the officially recommended faster and safer replacement for filter-branch:
# Install
pip install git-filter-repo
# Create a file listing the strings to remove
echo "sk_live_old_key_value" >> secrets-to-remove.txt
echo "postgresql://old:password@" >> secrets-to-remove.txt
# Rewrite all history
git filter-repo --replace-text secrets-to-remove.txt
# Force-push to every remote, including forks
git push origin --force --all
git push origin --force --tags
Note that this rewrites every commit hash. Every fork, PR, and local repository based on the old history must be realigned. If the repository was public, assume that its history has already been scraped. Cleaning is only remediation; rotation is what actually limits the damage.
Third, audit for unauthorized use. Check API call records such as CloudTrail and Audit Log in your cloud provider's console, inspect the database for abnormal queries, and review billing and usage in third-party services such as Stripe and OpenAI. If you find suspicious activity, report it through the security incident process instead of handling it quietly.
Summary
- Secrets must be separated from code - code is inherently public, even if it is private today.
- Three progressive layers: use
.envfor development, environment variables or K8s Secrets for containers, and Vault or Secrets Manager for production. A secrets management service is not an "advanced option" in production; it is foundational. - Rotation and auditing are the other half of secrets management: do not use one secret forever, and do not let every service read every secret.
- There are no frontend secrets:
NEXT_PUBLIC_is public configuration, not a secret. Credentials that can spend money must always remain on the server. - The incident-response order: rotate first, clean the history second, and audit last. Deleting the line is useless because Git history never forgets.
One line to remember: once a secret has entered Git, treat it as leaked and rotate it immediately instead of betting that nobody saw it. Security is always designed for the worst case, never around wishful thinking.
Related
Related Content
Article
Considerations for Deploying a Next.js Community Project with Docker
Technical trends, engineering practices, and an independent developer perspective on DevOps and Next.js.
Article
How Can OpenTelemetry Help You Observe AI Applications?
Technical trends, engineering practices, and an independent developer's perspective on DevOps and AI.
Article
Long-Running Tool Calls: The Terminal-State Protocol for Operation, Poll, and Cancel
After the initial request times out, the tool may still be running server-side; issuing a cancel does not mean the side effects have stopped. This article establishes an operation identity, polling watermark, cancellation race handling, and a single terminal-state acceptance point for a single long-running tool call.
Article
Agent Memory Erasure Propagation: From User Forgetting Request to Verifiable Purge
Deleting one row in the primary table won't clear vectors, summaries, caches, or restored backups. This article uses a deletion state machine, generational tombstones, derived lineage, and end-to-end canary verification to truly forget.