# Secrets Management (Don't Commit Your Keys)

> What counts as a secret and why it leaks, how to keep API keys and passwords out of your code and out of Git, and how teams store, inject, and rotate secrets safely in production.


---

# Secrets Management (Don't Commit Your Keys)

There's a moment that happens to almost every developer once. You wire up a third-party service, paste the API key right into the code to "get it working," push the branch - and three days later get an email from your cloud provider about a $4,000 bill, or a note from a security bot that found your key in a public repo. You weren't careless, exactly. Nobody ever showed you where keys are *supposed* to live, so you put it where the code could see it.

This guide fixes that gap. By the end you'll know exactly what counts as a secret, why secrets leak (it's almost always the same way), how to keep them out of your code and out of Git, and how real teams store and rotate them so a leak is a shrug instead of a disaster. The core idea is calm and simple: **a secret is a key to something that costs money or data, so you treat it like a key - you don't tape it to the front door.**

## How to read this

- **You leaked a secret right now and need to act?** Jump to the [leaked-secret cheat-card in Phase 3](03-real-secrets-management.md) and follow it top to bottom. Then come back and read the rest when your heart rate is normal.
- **Want it to finally make sense?** Read in order. Each phase builds on the one before, starting with the mental model that makes every rule afterward obvious.

## The phases

1. **[What Counts as a Secret & Why It Leaks](01-what-counts-as-a-secret.md)** - the mental model (a secret is a key to something that costs money or data), the four kinds you'll meet, and the number-one way they escape: hardcoded into source and committed.
2. **[Keep Them Out of Code](02-keep-them-out-of-code.md)** - config via environment variables and `.env`, `.gitignore` and `.env.example`, pre-commit secret scanners, and the hard truth that a committed secret lives in Git history forever - so you rotate it.
3. **[Real Secrets Management](03-real-secrets-management.md)** - how teams do it for production: a secrets manager that stores keys centrally, encrypted and access-controlled; injecting them at runtime instead of baking them into images; least privilege; and making rotation routine. Includes the leaked-secret cheat-card.

> This guide is about keeping secrets *safe*. Where config values *come from* in the first place - environment variables, `.env`, YAML, precedence - is its own guide: [Environment Variables & Config](/guides/env-vars-and-config). And if a secret has already reached a remote and you're wondering whether `git revert` hides it (it doesn't), see [Git Disaster Recovery](/guides/git-disaster-recovery).


---

# What Counts as a Secret & Why It Leaks

Before any rules about `.gitignore` or vaults, you need one idea in your head - the idea that makes every later rule feel obvious instead of arbitrary. People follow security advice badly when it's a list of memorized commandments; they follow it well when they understand what they're protecting and why it runs away. So: the mental model first, then the things that count, then how they escape.

## The mental model: a secret is a key

**What a secret actually is.** A secret is a **key to something that costs money or data.** That's it. Not "a string that looks random," not "anything in the config." The test is consequence: *if a stranger had this value, could they spend your money, read your users' data, or pretend to be you?* If yes, it's a secret. If no, it's just config.

Think of your house key: it isn't dangerous because it's shiny or secret-looking, it's dangerous because of what it *opens*. You don't leave it under the mat, you don't photocopy it for strangers, and if you lose it you change the lock. Every rule in this guide is one of those instincts applied to software.

```mermaid
flowchart TD
  q{"If a stranger had this value, could they<br/>spend my money, read my data, or be me?"}
  q -->|no| config["CONFIG<br/>(port, log level, feature flag, public API URL)<br/>- harmless if seen"]
  q -->|yes| secret["SECRET<br/>(anything that unlocks money or data)<br/>- never in code, never in Git, rotate if it leaks"]
```

💡 **Key point.** The thing that makes a value a secret is *what it unlocks*, not what it looks like. A short password to a production database is a secret; a long random string that's actually a public identifier is not. When unsure, ask the consequence question.

## The four kinds you'll meet

Almost everything you'll need to protect falls into one of four buckets. You don't have to memorize them - you'll recognize them in the wild once you've seen them named.

📝 **API key.** A single string a service gives you to prove "requests with this key are from my account." Think `sk_live_51H...` from a payments provider, or a key for a maps or email service. Whoever holds it can make calls *as you* - and you get the bill. This is the one that ends up in the surprise-invoice horror stories.

📝 **Database password (and connection strings).** The credentials your app uses to reach its database. Often bundled into one **connection string** like `postgres://app:hunter2@db.internal:5432/prod` - notice the password sits right in the middle of that URL. Whoever has it can read or delete every row your app can touch.

📝 **Token.** A string that proves an *identity* or a *granted permission*, usually time-limited. Session tokens, OAuth access tokens, personal access tokens for GitHub, JWTs. A token is often "an API key that expires" - still fully dangerous while it's alive.

📝 **Private key.** One half of a cryptographic key pair (the public half is meant to be shared; the **private** half must never be). SSH keys that log you into servers, TLS certificate keys, signing keys. These usually live in files like `id_rsa` or `private.pem`. Leaking a private key can hand someone your servers or let them forge things signed as you.

The reason to group them is that they share a single property: **possession is permission.** There's no second factor, no "are you sure?" - if the value is in someone's hands, they can use it. That's why we guard the value itself so fiercely.

## Why secrets leak: the number-one cause

Here's the plain part. Secrets rarely leak through some sophisticated hack. The overwhelmingly common way is mundane: **a developer writes the secret directly into the source code and commits it.**

It looks innocent in the moment:

```text
   // payment.js  - committed to the repo
   const stripe = require("stripe")("sk_live_51H8xK2eZvKYlo3a9qN...");
```

The code works, the feature ships, and the key is now sitting in your repository. If that repo is public - or ever becomes public, or is cloned to a laptop that's later lost - the key is out, and because it's a literal string in the code, it's trivially findable.

**Why people get this wrong.** The instinct is "it's in my private repo, so it's fine." But private isn't a fortress: repos get made public by accident, contractors get added and removed, laptops get stolen, backups get misconfigured. And as you'll see in Phase 2, once a secret is committed, **deleting it later doesn't remove it from Git's history** - it lingers in every clone forever. The safe assumption is the uncomfortable one: *anything that touches a repository should be considered potentially public.*

⚠️ **Gotcha - bots are watching, and they're fast.** Public code hosts are continuously scanned by automated bots looking for exactly these patterns. A real key pushed to a public repository can be found and abused within **minutes** of the push - not days. This is routine behavior for both attackers (hunting for free compute and data) and the good guys (GitHub's own secret-scanning service notifies you, and many providers auto-revoke keys it spots).

**Why this saves you later.** Once you internalize that a secret is a house key and the fastest way to lose it is to commit it, the next phase reads as common sense: get the key *out* of the code, keep the file it lives in *out* of Git, and assume that if it ever got out, you must change the lock.

## The other ways they leak (briefly)

Hardcoding-and-committing is number one by a wide margin, but a few others are worth knowing so you recognize them:

- **Logs.** Printing a request that contains an `Authorization` header, or logging the full config at startup, writes secrets into log files that get shipped to a logging service or shown in a stack trace.
- **Error pages and screenshots.** A debug error page that dumps environment variables, pasted into a ticket or chat, leaks whatever was on screen.
- **Sharing the wrong way.** Pasting a key into a public chat, a forum question, or an issue. Once it's somewhere you don't control, treat it as gone.

Each is the same root mistake as committing: **the secret ended up somewhere it could be seen.** Keep that frame - "where can this value be seen, and who can see there?" - and you'll catch leaks the rules don't explicitly cover.

## Recap

1. A **secret is a key to something that costs money or data.** The test is consequence, not appearance: could a stranger spend your money, read your data, or impersonate you with it?
2. The four kinds: **API keys**, **database passwords / connection strings**, **tokens**, and **private keys.** They share one rule - *possession is permission.*
3. The number-one leak is **hardcoding a secret into source and committing it.** "It's private" is not protection; repos go public, laptops walk off, and committed secrets stay in history.
4. ⚠️ Bots scan public repos continuously - a leaked key can be abused within **minutes** of being pushed.
5. The universal lens: **where can this value be seen, and who can see there?** Logs, error pages, and careless sharing leak secrets the same way commits do.

Next: the practical mechanics of getting secrets out of your code and keeping them out of Git - and what to do about the ones already in there.


---

# Keep Them Out of Code

In Phase 1 we landed on the rule: a secret is a house key, and the fastest way to lose it is to commit it. Now the practical question - *where does the key go instead?* The answer is a small, well-worn pattern that every professional project uses, and once you've set it up once, it becomes muscle memory. We'll build it up one layer at a time, then face the uncomfortable case: what to do when a secret is already in your history.

## Layer 1: Move the secret out of the source

**What it actually is.** Instead of writing the key as a literal string in your code, you read it from the **environment** at runtime - the same `NAME=value` mechanism the operating system hands every process. Your code asks for `STRIPE_SECRET_KEY` by name; *where that value comes from* is decided outside the code.

The hardcoded version from Phase 1 becomes this:

```text
   // payment.js  - the secret is no longer in the file
   const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
```

Now the source code is safe to commit - it mentions the *name* of the secret, never the value. The value lives in the environment, and the environment is per-machine: your laptop, staging, and production each supply their own.

> 📝 If environment variables, `process.env`, and `.env` files are unfamiliar mechanics, the full walkthrough is in [Environment Variables & Config](/guides/env-vars-and-config) - here we're focused only on their *security*.

## Layer 2: The `.env` file - and keeping it out of Git

For local development you keep the actual values in a `.env` file, one per line:

```text
STRIPE_SECRET_KEY=sk_test_51H8xK2eZvKYlo3a9qN...
DATABASE_URL=postgres://app:hunter2@localhost:5432/myapp_dev
```

This file holds real, live secrets. So the single most important rule in this whole phase: **it must never enter Git.** You tell Git to ignore it by adding one line to a `.gitignore` file in your project root:

```text
.env
```

Then verify Git is actually ignoring it - don't assume, check:

```console
$ git status
On branch main
nothing to commit, working tree clean
```
*What just happened:* Even though `.env` exists on disk with your real keys inside it, it doesn't appear in `git status` - the `.gitignore` entry told Git to treat it as if it weren't there, so you can't stage or commit it by accident. If `.env` *had* shown up in that list, it is **not** ignored yet - fix the `.gitignore` first.

⚠️ **Gotcha - `.gitignore` only ignores files Git isn't already tracking.** If you committed `.env` *before* adding it to `.gitignore`, the ignore rule does nothing - Git keeps tracking the file it already knows. Explicitly untrack it:

```console
$ git rm --cached .env
rm '.env'
$ git commit -m "Stop tracking .env"
```
*What just happened:* `git rm --cached` removed `.env` from Git's tracking *without deleting it from your disk* (the `--cached` flag is the important part). From the next commit on, Git ignores it. But this does **not** undo the fact that the secret was already committed - more on that below.

## Layer 3: `.env.example` - so teammates know what's needed

Since `.env` itself never gets committed, a new teammate cloning the repo has no idea which variables to set. The fix is a committed companion file, **`.env.example`**, listing every variable *name* with placeholder or blank values - never real ones:

```text
STRIPE_SECRET_KEY=your_stripe_test_key_here
DATABASE_URL=postgres://user:password@localhost:5432/dbname
```

This file is safe to commit precisely because it contains no real secrets, just the shape of what's needed. The onboarding ritual becomes: copy `.env.example` to `.env`, fill in the real values you've been given. New developer productive in two minutes, zero secrets in the repo.

💡 **Key point.** The split is the whole trick: **`.env` is real and ignored; `.env.example` is fake and committed.** One tells your app what to do; the other tells your teammates what to fill in.

## Layer 4: A pre-commit secret scanner - a net under the trapeze

The pattern above relies on you remembering. Humans forget - especially the day you paste a key "just to test something fast." A **pre-commit secret scanner** is an automated check that runs *before* each commit is recorded and refuses the commit if it spots something that looks like a secret.

Two common tools are [git-secrets](https://github.com/awslabs/git-secrets) and [gitleaks](https://github.com/gitleaks/gitleaks), often wired in via the [pre-commit](https://pre-commit.com/) framework. The exact setup varies, but the experience looks like this:

```console
$ git commit -m "Add payment retry logic"
gitleaks: detected hardcoded secret in payment.js
  Rule:    stripe-access-token
  File:    payment.js
  Line:    14
gitleaks detected 1 leak - commit aborted
```
*What just happened:* The scanner recognized the pattern of a Stripe key in `payment.js` and **stopped the commit from being created at all.** Nothing entered Git. You delete the line, move the value to your `.env`, and commit again - caught at the last possible safe moment.

This is a net, not a guarantee - scanners can miss novel patterns and occasionally flag harmless strings. But catching the obvious cases automatically, on every commit, dramatically lowers the odds of the classic mistake. Set it up once per repo and forget about it.

## The hard truth: a committed secret lives in history forever

Now the part people most want to be untrue. Suppose you already committed a secret - maybe even pushed it. The instinct is to delete the line, commit the fix, and feel relieved. **That relief is false.**

Git doesn't store files as a single "current" state; it stores **history** - every commit is a snapshot, and old snapshots don't disappear when you change the latest one. Deleting the secret in a new commit just means the *newest* snapshot no longer has it. The commit where you *added* it is still right there, one step back in the log, secret intact - anyone can check out that older commit and read it.

```mermaid
flowchart TD
  c3["commit C3 - 'Remove secret'<br/>secret gone HERE..."]
  c2["commit C2 - 'Add payment logic'<br/>...but it's STILL RIGHT HERE, full value, forever"]
  c1["commit C1 - 'Initial commit'"]
  c3 --> c2 --> c1
```

And if you *pushed*, it's worse: the secret now exists in every clone anyone has made, and quite possibly in a scanning bot's database already. Even genuinely rewriting Git history to scrub it can't recall the copies already out in the world.

⚠️ **Gotcha - removing the file is NOT enough. Rotate the secret.** Because you cannot reliably un-leak a value that reached a repository, the only real fix is to **rotate** it: go to the service, revoke the old key, and issue a new one. Once the old key is dead, it doesn't matter who has the old value - it unlocks nothing. Rotation is the lock-change for a lost house key. (For why `git revert` and even force-pushing don't erase a pushed secret, see [Git Disaster Recovery](/guides/git-disaster-recovery).)

🪖 **War story.** A teammate committed a cloud access key, noticed an hour later, and pushed a commit deleting the line - then went home feeling tidy. The key was scraped and used to spin up crypto-mining servers before morning, because the *deletion* commit changed nothing about the *addition* commit sitting one step back. The step skipped: revoke the key in the cloud console. Deleting the line treats the symptom; rotating treats the leak.

## Recap

1. **Read secrets from the environment**, never as literals in source: `process.env.STRIPE_SECRET_KEY`, not the key itself.
2. Keep real values in a **`.env` file** and add `.env` to **`.gitignore`** - then run `git status` to confirm it's actually ignored.
3. If `.env` was already committed, untrack it with `git rm --cached .env` (ignoring doesn't retroactively untrack).
4. Commit a **`.env.example`** with placeholder values so teammates know what to set; never put real secrets in it.
5. Add a **pre-commit secret scanner** (gitleaks, git-secrets) as an automated net that blocks the commit before a leak happens.
6. ⚠️ A committed secret lives in **Git history forever** - deleting it from the latest commit is not enough. **Rotate it**: revoke the old value and issue a new one.

Next: how teams manage secrets centrally for production - with a cheat-card for the day one gets out.


---

# Real Secrets Management

A `.env` file on your laptop is fine for one developer. But picture a team of twelve, three environments, and forty services that each need a key. Who has the production database password? How do you change it everywhere when someone leaves? How do you know who *read* it last week? `.env` files copied around in chat answer none of that - and on a real system, those questions are the whole job. The relief here is structural: instead of secrets scattered across laptops, they live in one guarded place that knows who touched what.

But first, because you might be reading this *while a key is loose*, the cheat-card.

## The leaked-secret cheat-card

> **A secret got out. Stay calm and go in this order: revoke, rotate, audit. Speed beats perfection - kill the key first, investigate after.**

| Step | What you do | Why it's in this order |
|---|---|---|
| **1. Revoke** | Go to the service and **disable/delete the leaked key immediately.** | A dead key can't be abused while you figure out the rest - the lock change, done first. |
| **2. Rotate** | Issue a **new** key, put it in your secrets store, and redeploy so the app uses it. | Restores service on a clean credential; the old value is now worthless no matter who has it. |
| **3. Audit** | Check the service's **logs / access history** for use you didn't make, and find *how* it leaked so it can't recur. | Tells you whether damage was done, and closes the hole (a bad `.gitignore`, a logged header, a shared screenshot). |

Three more notes for the moment of panic: **assume it was seen** (bots are fast, per Phase 1); **don't try to scrub Git history first** (revoking the key makes the leaked copy harmless instantly, which history-rewriting never can - see [Git Disaster Recovery](/guides/git-disaster-recovery)); and **tell your team** so nobody is surprised by the rotation or the new charges.

## A secrets manager: one guarded place

**What it actually is.** A **secrets manager** (or **vault**) is a dedicated service whose entire job is to hold secrets safely and hand them out carefully. Instead of living in files on developer laptops, secrets live in one central system that does four things a flat file can't:

```mermaid
flowchart TD
  app["app asks at startup<br/>'give me DB_PASSWORD'"] --> sm
  human["human asks via CLI/console<br/>(request is logged)"] --> sm
  sm["Secrets Manager<br/>• ENCRYPTED at rest<br/>• ACCESS-CONTROLLED - who can read which secret<br/>• AUDITED - every read logged (who, when)<br/>• ROTATABLE - change a secret in ONE place"]
```

📝 **Terminology.** The category includes self-hosted **HashiCorp Vault** and managed cloud offerings - **AWS Secrets Manager**, **Google Secret Manager**, **Azure Key Vault**. They differ in features and where they run, but the core promise is the same: encrypted central storage, fine-grained access control, an audit trail, and a single place to rotate.

**Why this beats `.env` for teams.** Every property maps to a question `.env` couldn't answer. *Who has the prod password?* → access control says exactly who. *Did anyone use it suspiciously?* → the audit log shows every read. *How do I change it everywhere?* → rotate it once and every app picks up the new value. The secret stops being a thing you copy and starts being a thing you *request*.

## Inject at runtime - don't bake secrets into the image

**What it actually is.** **Runtime injection** means your application receives its secrets *when it starts up*, fetched fresh from the secrets manager (or set as environment variables by the platform) rather than written into the build.

**Why people get this wrong.** A tempting shortcut, especially with containers, is to copy the `.env` file or hardcode a key into the **Docker image** during the build so "it's all in one place." Don't. A built image is an artifact that gets pushed to a registry, pulled to many machines, and often kept around in old versions - a secret baked into it is copied into every layer, every pull, every cached version. The Phase 1 problem all over again, just in a different file format.

```mermaid
flowchart LR
  subgraph bad["BAD"]
    b1[build time] --> b2["image: app code + SECRET baked in"] --> b3["registry, every host, old versions<br/>(secret copied everywhere)"]
  end
  subgraph good["GOOD"]
    g1[build time] --> g2["image: app code only, no secrets"]
    g3[start time] --> g4["app fetches SECRET from manager / platform env<br/>holds it in memory"]
  end
```

The image stays clean and shareable; the secret only ever lives in memory on the running machine, supplied at the last moment by something access-controlled and audited.

## Least privilege: a key should open one door

⚠️ **Gotcha - over-powered keys turn a small leak into a big one.** **Least privilege** means each key is granted *only* the permissions it actually needs, nothing more. The reporting service that only ever reads should get a **read-only** key, not an admin key that can also delete. A webhook handler that only writes to one table shouldn't hold credentials for the whole database.

The reason is blast radius. Possession is permission (Phase 1), so the damage a leaked key can do is exactly the set of permissions you gave it. A leaked read-only analytics key is an annoyance; a leaked god-mode admin key is a catastrophe. Scoping every key down means that *when* one leaks - and over enough time, one will - the worst case is small. You're not preventing the leak with least privilege; you're capping what it can cost.

## Rotation: assume every secret will eventually leak

Here's the mindset shift that separates anxious teams from calm ones. Don't treat a leak as a rare failure to be horrified by - treat it as **inevitable over a long enough timeline**, and build so that handling it is routine.

**What it actually is.** **Rotation** is replacing a secret with a fresh one on a regular cadence (and immediately on any suspected leak). If a key is changed every ninety days regardless, any copy an attacker quietly grabbed has a limited shelf life, and the *process* of changing it becomes routine rather than terrifying.

💡 **Key point.** A team that has never rotated a secret will rotate slowly and nervously the day they're forced to - exactly when they can least afford to fumble. A team that rotates on a schedule treats an emergency rotation as "the Tuesday thing, done early." Many secrets managers can even rotate certain credentials automatically. Make rotation routine, and the cheat-card at the top of this phase becomes a procedure you've run a dozen times, not a crisis you've never rehearsed.

**Why this saves you later.** Put the pieces together and a leak stops being a disaster. The key was **least-privileged**, so it couldn't do much. It was in a **secrets manager**, so you revoke and rotate it in one place and the **audit log** tells you if it was used. It was **injected at runtime**, so it isn't frozen into a dozen images you'd have to rebuild. And because you **rotate routinely**, the response is a familiar drill.

## Recap

1. **Leaked-secret response is revoke → rotate → audit**, in that order - kill the key first, investigate after.
2. A **secrets manager** (Vault, AWS Secrets Manager, Google Secret Manager, Azure Key Vault) stores secrets centrally - **encrypted, access-controlled, and audited** - and lets you rotate in one place.
3. **Inject secrets at runtime**, fetched at startup; never bake them into a built image, where they get copied everywhere.
4. ⚠️ Apply **least privilege** to every key so a leak's blast radius stays small.
5. **Rotate routinely.** Assume any secret will eventually leak; regular rotation limits an old key's life and turns emergency rotation into a rehearsed, boring move.

---

## That's the whole skill

Look back at where you started: a hardcoded key and a sinking feeling. Now you can name what a secret is, keep keys out of your code and out of Git, know that a committed secret means *rotate, not delete*, and understand how teams store, scope, inject, and rotate secrets so a leak is contained and recoverable. Treat every secret like a house key, and you'll sleep fine.
