Update, October 31, 2026: This month gave the post a real-world example. At least nine AI developer platforms disclosed breaches in October, and the attackers were specifically hunting stored environment variables, especially AI API keys, which they then validated and resold. One platform told every customer to assume their env vars had been decrypted and rotate everything. I covered the incidents and a rotation checklist in Black October: 9 AI Dev Platforms Were Breached. Rotate Your Keys Today. This post is the longer-term fix: where your secrets should live so the next incident is a ten-minute rotation, not a crisis.
TL;DR: A shared .env file isn't a secrets strategy, it's a list of every place your production credentials have been copied to. For a small team, five habits fix most of the risk: read config from the environment and fail fast, keep production values in a managed secret store, replace stored CI keys with short-lived OIDC credentials, scan for leaks before they're pushed, and rotate first when something slips. Code for each is below (Django and GitHub Actions, but the ideas carry over).
Unpopular opinion: the riskiest file in most small codebases isn't in the codebase. It's the .env that got pasted into a chat on someone's first day.
Here's what that usually looks like. There's a .env with the production database password, the payment provider's live key and an AWS access key. It was shared in a DM when someone joined. It's been copied onto four laptops and a CI server. Nobody is sure whether the contractor who left last year still has a copy, and somewhere in the Git history there's an older version that was committed by accident and "removed" in the next commit.
None of this is unusual, and none of it needs an enterprise security programme to fix. Below is the approach we use when we take over or set up a project: where secrets actually leak, where they should live instead, and what to do when one gets out anyway.
Start with an inventory, not a tool
Before choosing a secret manager, write down what you actually have. A plain table in the repo's docs/ folder is enough:
| Secret | Used by | Where it lives today | Who can read it | Rotation |
|---|---|---|---|---|
DATABASE_URL (prod) | web, worker | .env on server, CI vars | 4 devs, CI | never |
STRIPE_API_KEY (live) | web | .env, password manager | 2 devs | never |
| AWS access key | CI deploy | CI settings | anyone with repo admin | never |
OPENAI_API_KEY / ANTHROPIC_API_KEY | web, worker, local AI tools | .env, hosting dashboard, two laptops | 3 devs, platform | never |
The "Rotation: never" column is the honest one, and it's the one that matters. Every row where "who can read it" includes a person rather than a service is a row you'll be rotating when that person leaves.
Where secrets actually leak
Committed .env files get the attention, but they're only one route. In practice, secrets also escape through:
- CI logs, when a build step prints its environment, runs with
set -x, or a failing command echoes its arguments. - Error trackers, which capture local variables, request headers and settings when an exception is reported.
- Docker images, when a
COPY . .step bakes the.envfile into a layer that ships to a registry. Deleting the file in a later layer doesn't remove it from the earlier one. - Chat, email, tickets and screenshots, which are rarely deleted and are searchable by everyone in the workspace.
- Laptops, including old ones, backups of old ones, and the shell history on all of them.
- AI coding tools and agents, which read files in your project to build context. If an assistant can open your
.env, its contents can end up in a prompt, a log or a transcript. - Every platform you pasted a key into. Hosting, CI, notebooks and agent platforms all store environment variables, and Black October showed attackers going after exactly that.
Good secrets management closes all of these, not just the repository.
Read configuration from the environment, and fail fast
The application shouldn't care where a secret comes from. It reads environment variables and refuses to start if a required one is missing, so a misconfigured deploy fails loudly instead of quietly running with a default. This is the old Twelve-Factor rule of storing config in the environment, and it's still the right baseline:
# settings.py import os from django.core.exceptions import ImproperlyConfigured def require_env(name: str) -> str: value = os.environ.get(name) if not value: raise ImproperlyConfigured(f"Missing required environment variable: {name}") return value SECRET_KEY = require_env("DJANGO_SECRET_KEY") STRIPE_API_KEY = require_env("STRIPE_API_KEY") DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true" Two details matter here. First, there is no fallback value for secrets. A line like os.environ.get("SECRET_KEY", "dev-key") is how a development key ends up signing production sessions. Second, non-secret flags like DEBUG default to the safe value, so forgetting to set them can't open anything up.
Keep a .env file for local development if it's convenient, but only with development credentials, and keep it in both .gitignore and .dockerignore:
# .gitignore and .dockerignore .env .env.* !.env.example Commit a .env.example with every variable name and a placeholder, so a new developer knows what to set without anyone sending them real values:
# .env.example DJANGO_SECRET_KEY=change-me DATABASE_URL=postgres://app:app@localhost:5432/app STRIPE_API_KEY=sk_test_replace_me If your local setup needs real third-party sandbox keys, give each developer their own where the provider allows it. Shared sandbox keys get pasted around exactly like production ones.
Keep production secrets in a secret manager
Production secrets belong in a managed store: AWS Secrets Manager, Azure Key Vault, Google Secret Manager, or a tool such as Doppler, 1Password or HashiCorp Vault. The benefits are concrete. Reads are logged, access is granted per service rather than per person, and rotating a value happens in one place instead of on four laptops.
Most platforms can inject secrets as environment variables at deploy time (ECS task definitions, Kubernetes secrets backed by an external store, Azure App Service Key Vault references), which keeps the application code from the previous section unchanged. Where that isn't available, load them once at start-up:
# secrets_loader.py import json import os from functools import lru_cache import boto3 @lru_cache(maxsize=None) def load_secret(secret_id: str) -> dict: client = boto3.client("secretsmanager") response = client.get_secret_value(SecretId=secret_id) return json.loads(response["SecretString"]) def export_secret(secret_id: str) -> None: """Copy keys from one JSON secret into os.environ before settings load.""" for key, value in load_secret(secret_id).items(): os.environ.setdefault(key, value) Call export_secret("prod/shop/app") at the top of wsgi.py / asgi.py (and in your Celery entry point), before Django imports settings. setdefault means an explicitly injected variable still wins, which keeps local overrides and tests simple.
The application's IAM role, not a stored access key, grants permission to read that one secret:
{ "Effect": "Allow", "Action": "secretsmanager:GetSecretValue", "Resource": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:prod/shop/app-*" } That's the point of the whole exercise: the only credential involved is the identity of the machine running the code, and nobody ever has to copy it anywhere.
Remove long-lived keys from CI
CI often holds the most powerful credentials with the least oversight. A static cloud access key in CI settings is valid until someone remembers to rotate it, and anyone who can edit a workflow can print it. GitHub Actions can instead request short-lived credentials using OpenID Connect, with no stored key at all:
# .github/workflows/deploy.yml permissions: id-token: write contents: read jobs: deploy: runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/github-deploy aws-region: eu-west-1 - run: ./scripts/deploy.sh On the cloud side, the role's trust policy decides which workflow runs can assume it. This is the part people skip, and it's the part that matters:
{ "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:acme/shop:environment:production" } } } Scoping sub to one repository and one GitHub environment means a pull request from a fork, or a workflow on a random branch, can't deploy to production. Add required reviewers on the production environment and you also get a human approval step for free. Azure and Google Cloud support the same federated pattern.
Keep your AI tools away from your secrets
AI coding assistants change the threat model for the local .env. They're useful because they read your project, and that includes files you'd never paste into a chat yourself. Two habits help.
First, tell the tools what they can't read. Most assistants have an ignore or permission mechanism. In Claude Code, deny rules in the project settings block reads of matching files. In .claude/settings.json:
{ "permissions": { "deny": [ "Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)" ] } } Cursor has a .cursorignore file with .gitignore syntax, and other tools have their own equivalent. Commit these files so the whole team gets the same rules. But treat them as a seatbelt, not a lock: an agent that can run shell commands can still cat a file or print the environment, and an extension can be compromised.
Second, and this is the real fix: don't keep production secrets on developer machines at all. The local .env should hold development credentials only, including a separate AI key per developer with a low spend limit. Production AI keys belong in the secret manager with everything else, scoped to the models and regions the app actually uses, and with a hard budget on the provider side. A stolen AI key works like a payment card with no limit, so give it one.
Catch leaks before they land
The cheapest leak to fix is the one that never reaches the remote. A pre-commit hook scans staged changes:
# .pre-commit-config.yaml repos: - repo: https://github.com/gitleaks/gitleaks rev: v8.21.2 # pin to a current release hooks: - id: gitleaks Local hooks can be skipped with --no-verify, so run the same scan in CI, and turn on your Git host's push protection where your plan includes it. Run a one-off scan of the full history too. It's uncomfortable, and it's better to find out now:
gitleaks git --redact -v . Then close the other leak routes:
# views.py from django.views.decorators.debug import ( sensitive_post_parameters, sensitive_variables, ) @sensitive_variables("api_key", "token") @sensitive_post_parameters("password", "card_number") def connect_provider(request): api_key = request.POST["api_key"] token = exchange_for_token(api_key) ... Those decorators hide the named values from Django's error reports. Do the equivalent in your error tracker's settings (most have a "scrub fields" or before_send hook), and check that request headers like Authorization are filtered there too.
For Docker builds that need a secret, such as a private package index token, use a build secret mount so it never lands in a layer:
# syntax=docker/dockerfile:1 RUN --mount=type=secret,id=pip_token \ PIP_INDEX_URL="https://__token__:$(cat /run/secrets/pip_token)@pypi.example.com/simple" \ pip install -r requirements.txt docker build --secret id=pip_token,env=PIP_TOKEN . Make rotation boring
Rotation only happens if it's cheap. Two things make it cheap.
Support two valid values at once during the switch. Django does this for its own signing key with SECRET_KEY_FALLBACKS (Django 4.1+): put the new key in SECRET_KEY, the old one in the fallbacks, deploy, and remove the old one once existing sessions and tokens have aged out.
SECRET_KEY = require_env("DJANGO_SECRET_KEY") SECRET_KEY_FALLBACKS = [ k for k in os.environ.get("DJANGO_SECRET_KEY_FALLBACKS", "").split(",") if k ] For database passwords and API keys (AI provider keys included), the same idea works: create the new credential, deploy with it, confirm traffic has moved, then revoke the old one. Write those four steps into a short runbook per secret, and the next rotation is a ten-minute task instead of an incident.
When a secret leaks anyway
The order matters:
- Revoke or rotate first. Deleting the commit or the message doesn't help; assume it has already been copied. Automated scanners watch public repositories continuously.
- Check the provider's access logs for use you don't recognise, from the time of the leak onwards.
- Then clean up: rewrite history with
git filter-repoif the repo is private and it's worth it, purge CI logs, delete the chat message. - Fix the route. Ask how it got out, and close that path, so the same leak doesn't happen next month.
Step 3 is the one people instinctively do first, and it's the least important. A rotated secret in your Git history is harmless. A live one that you deleted from the latest commit is not.
A one-week plan for a small team
If all of this feels like a lot, it fits in a week of spare hours:
- Day 1: inventory table; add
gitleaksto pre-commit and CI; scan history. - Day 2:
require_envin settings;.env.example;.dockerignore. - Day 3: move production values into a secret manager; switch the app to a role.
- Day 4: OIDC for CI deploys; delete the stored cloud key.
- Day 5: rotate everything that was in the shared
.env, using the runbook.
Day 5 is the one that actually removes the risk. Until the old values are rotated, everyone who ever had the file still has working production credentials.
If you're also tightening the rest of your production setup, we wrote up how we approach deploying and scaling a production-ready Django app, which covers the runtime side this post leaves out.
Wrapping up
You don't need a dedicated security team to manage secrets well. Read configuration from the environment and fail fast, keep production values in a secret manager, use short-lived identities instead of stored keys, scan before you push, make rotation cheap, and rotate first when something slips through. Each step is small. Together they turn "a .env file that's been everywhere" into something you can actually audit.
Question for you: be honest, which AI tools on your laptop can read your .env right now, and when did your team last rotate the keys inside it?
Cover photo by Maria Ziegler on Unsplash.