Where secrets leak in a Django deployment and what to do about it: file permissions, environment variables versus files, build arguments baked into image layers, git history you cannot really erase, encrypted-at-rest storage, and a rotation process.
Most credential leaks are not sophisticated. A key gets committed during a late-night fix, an environment file ends up in a directory the web server happily serves, a build argument is baked into a public image layer, or a debug page renders the entire configuration to whoever triggered the error.
This guide covers where secrets escape in a Django deployment, how to store them so that a single mistake is not catastrophic, and how to build the rotation habit that turns a leak from an incident into an inconvenience.
More than people list. The database password and the API keys are obvious. Less obvious: the Django secret key, session and signing keys, TLS private keys, webhook signing secrets, OAuth client secrets, and the credentials your backups use.
A useful test: if this value became public, would I have to change something in a hurry? If yes, it is a secret, and it needs the same handling as the database password — regardless of how harmless it looks in a settings file.
SECRET_KEY signs session cookies, password reset tokens, and anything passed through
the signing framework. Someone holding it can forge a session cookie for any user, including staff,
without touching your database.
It follows that a secret key committed to a repository — even a private one, even years ago — is a standing authentication bypass. Rotating it invalidates all sessions and outstanding password reset links, which is inconvenient for one afternoon and considerably better than the alternative.
The common pattern is a file of key-value pairs loaded at startup. It works well, provided two things are true.
chown appuser:appuser /srv/django-app/.env
chmod 600 /srv/django-app/.env
First, only the account running the application may read it. A world-readable environment file on a shared server hands every credential to every user on the machine.
Second — and this one causes real breaches — it must not live anywhere the web server can serve. If your static or media location is configured slightly too broadly, a request for the file returns its contents to anyone who asks. Keep secrets outside every served directory, and verify by requesting the path yourself:
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/.env # want 404
Both are used; both have weaknesses worth knowing. Environment variables are inherited by every child process your application spawns, appear in process listings on some systems, and are commonly captured verbatim by crash reporters and error trackers — which then transmit them to a third party and display them in a web interface.
Files avoid the inheritance problem but persist on disk, get copied by backup jobs, and survive in snapshots. Neither is strictly safer. What matters is that you know which exposures apply and have addressed them: scrub sensitive keys in your error tracker, and encrypt or exclude the file in your backups.
A frequent and expensive mistake. Passing a secret as a build argument, or copying an environment file into an image, writes it into a layer — permanently. Deleting the file in a later step does not remove it; the earlier layer still contains it, and anyone who can pull the image can extract it.
# Wrong: the value is now in the image history
ARG API_KEY
RUN ./configure --key=$API_KEY
# Right: available during build, not stored in a layer
RUN --mount=type=secret,id=api_key ./configure --key=$(cat /run/secrets/api_key)
Better still, do not give secrets to the build at all. A build produces an artefact; configuration belongs at runtime. If you publish images anywhere shared, audit their history:
docker history --no-trunc myimage:latest | grep -i -E "key|token|password"
When a secret is committed, removing it from the current files changes nothing — it remains in history, in every clone, in every fork, and in any platform cache. Tools exist to rewrite history, they require every collaborator to re-clone, and they still do not reach copies that already left your control.
Treat a committed secret as compromised, always. Rotate it first — that is the step that actually protects you — and only then clean up the repository. The reverse order provides a satisfying feeling and no security.
Prevention is far cheaper than rotation. A pre-commit hook that scans staged changes for credential patterns costs nothing and catches the majority of accidents.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
Pair it with a strict ignore file and, critically, a committed example file that documents which variables exist without holding any values. New developers copy the example; nobody needs to ask what to fill in, and nobody is tempted to commit a working file "temporarily".
A middle path between plain files and a full secret store: encrypt the values, commit the encrypted file, and decrypt at deploy time. Tools built on modern encryption make this straightforward, and because only the values are encrypted the file still diffs sensibly in review.
sops --encrypt --age $AGE_PUBLIC_KEY secrets.yaml > secrets.enc.yaml
sops --decrypt secrets.enc.yaml > /srv/django-app/.env
You now have versioned, reviewable secrets with a clear audit trail — and one key to protect instead of twenty. That key must live somewhere other than the repository, which is the whole point.
Purpose-built stores add dynamic credentials, automatic rotation, fine-grained access policies and audit logging. For a single application on a couple of servers they are usually more operational burden than benefit.
The threshold is roughly: multiple teams needing different access, a compliance requirement for audit trails, or enough services that manual rotation has become impractical. Below that, encrypted files with disciplined permissions are a defensible choice — and a simple system that is actually followed beats a sophisticated one that gets bypassed.
With DEBUG = True, an unhandled exception renders a page containing your settings,
environment and local variables. Django masks values whose names look sensitive, but the heuristic
is name-based — anything called something unexpected is printed in full.
DEBUG = False
ALLOWED_HOSTS = ["example.com", "www.example.com"]
Verify in production rather than trusting the setting file, because an environment variable may be overriding it. Requesting a deliberately broken URL and receiving a plain error page is a five-second check worth performing after every deployment.
Error trackers, logging platforms and performance monitors capture request context — headers, cookies, environment, sometimes POST bodies. That is exactly what makes them useful, and it means your credentials and your users' personal data are flowing to an external service.
Configure scrubbing deliberately: strip authorisation headers, session cookies, password fields and anything matching your key naming. Then trigger a test error and read what actually arrived. Almost everyone who does this the first time finds something they did not intend to send.
Ask the question concretely. In many small teams the honest answer is "everyone who has ever had server access, including the contractor from last year". Access to a machine that holds an unencrypted environment file is access to every credential that application uses.
Reduce the set of people who need production credentials at all. Deployment automation can hold them without humans reading them; developers can work against local values. Every person removed from that list is one fewer laptop whose compromise becomes your incident.
Most teams rotate credentials only after a scare, which means the process is unpractised at exactly the moment it needs to be fast. Make it routine instead, so the steps are known.
Write down, per credential: where it is used, how a new one is issued, whether old and new can be valid simultaneously, and what breaks during the change. That last question decides whether rotation is a five-minute task or a maintenance window — and knowing it in advance is most of the work.
Where a credential supports two active values, rotation is genuinely painless: issue the new one, deploy it, verify, revoke the old one. Prefer credentials that support this whenever you have a choice.
Rotate first. Not investigate first, not discuss first — rotate. A revoked credential cannot be used regardless of who saw it or for how long.
Then determine the exposure window and check the logs for use of that credential in the period, because the interesting question is not whether it leaked but whether anyone used it. Clean up the source afterwards, and write down what allowed it — the process gap is the thing that will otherwise produce the same incident again.
It happens gradually and for understandable reasons. Someone needs to reproduce a bug against real data, copies the production environment file to their laptop, and never removes it. Six months later those credentials exist on an unencrypted machine that travels, is used for personal browsing, and may be shared with a family member.
Give every environment its own credentials. Local development should point at a local database with generated data and sandbox keys from every third party you use. Where a provider offers test credentials, use them — a leaked test key costs nothing.
If reproducing an issue genuinely requires production data, copy the data with the sensitive fields anonymised rather than copying the credentials. That distinction is the whole difference between a lost laptop being an annoyance and being a breach notification.
Make the safe path the easy path. A small management command that produces a scrubbed dump removes the temptation to reach for the real thing.
class Command(BaseCommand):
def handle(self, *args, **opts):
for user in User.objects.all():
user.email = f"user{user.pk}@example.invalid"
user.first_name = "Test"
user.last_name = f"User{user.pk}"
user.set_unusable_password()
user.save()
Run it against a restored copy, never against production. The .invalid top-level
domain is reserved precisely for this: it can never resolve, so an accidental mail run from a
development environment cannot reach a real person.
You do not need a programme to make meaningful progress. Six checks answer most of the question.
# 1. Are environment files readable by others?
find /srv -name ".env*" -exec ls -l {} \;
# 2. Is anything sensitive reachable over HTTP?
for p in .env .git/config settings.py backup.sql; do
printf "%-16s " "$p"; curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/$p"
done
# 3. Is debug genuinely off?
curl -s https://example.com/this-page-does-not-exist | grep -ci "traceback"
# 4. Does the repository history contain credentials?
gitleaks detect --source . --no-git=false
# 5. Do published images carry secrets in their layers?
docker history --no-trunc myimage:latest | grep -i -E "key|token|password"
# 6. Who can currently reach the servers?
sudo awk '{print $3}' /home/*/.ssh/authorized_keys /root/.ssh/authorized_keys 2>/dev/null
Every result should be boring: mode 600, four 404s, no traceback, no findings, no matches, and a list of keys you recognise. Anything else is a specific, fixable item — and finding it during a scheduled ten minutes is considerably cheaper than finding it any other way.
Treat anything you would have to change in a hurry as a secret, including the Django signing key. Keep environment files owned by the application account, mode 600, and outside every served path — then verify by requesting them. Know the exposure profile of variables versus files and mitigate the one you chose. Never pass secrets as build arguments; they persist in image layers. Assume anything committed to git is public and rotate it before cleaning up. Prevent with commit scanning and an example file. Encrypt at rest and version alongside the code until scale justifies a dedicated store. Turn debug off and confirm it. Scrub what your error tracker sends. And rehearse rotation while nothing is on fire.