{"course":{"slug":"cloud-and-shipping","title":"Shipping It","url":"https://addaly.com/learn/cloud-and-shipping","level":"intermediate","track":"build"},"lesson":{"slug":"secrets-and-env","title":"The key you must not commit","url":"https://addaly.com/learn/cloud-and-shipping/secrets-and-env","position":10,"of":74,"minutes":8,"answer":"A leaked key is only fixed by rotating it; scrubbing git history does not un-leak anything.","keyIdea":"A leaked key is only fixed by rotating it; scrubbing git history does not un-leak anything."},"body":{"format":"markdown","text":"## Configuration that changes per environment does not belong in the file\n\nYour database URL is different on your laptop and in production. Your Stripe key is different in test and live. So they are not code. They are inputs.\n\n```js\nconst key = process.env.OPENROUTER_API_KEY;\nif (!key) throw new Error(\"OPENROUTER_API_KEY is not set\");\n```\n\nCheck for the value **at startup, not at use**. A missing secret that crashes the process on boot is a failed deploy you notice in thirty seconds. A missing secret discovered by a 500 error on the one route that uses it is a Saturday.\n\n## .env is a local convenience, not a deployment mechanism\n\n- `.env` goes in `.gitignore`. Put it there before you create the file, not after.\n- `.env.example` gets committed, with the variable **names** and no values. It is documentation.\n- On the host, you set the real values through the platform:\n\n```\nwrangler secret put BETTER_AUTH_SECRET\nfly secrets set DATABASE_URL=postgres://...\n```\n\nThose live encrypted on the platform and are injected into the process. Nobody copies a `.env` to a server over SSH.\n\n## Anything prefixed for the browser is public\n\n`NEXT_PUBLIC_`, `VITE_`, `REACT_APP_`: these prefixes exist to tell the build tool \"substitute this value into the JavaScript bundle\". That bundle is downloaded by every visitor. View source, search, done.\n\nThis is the most common leak I see, and it has a predictable cause: the build tool said the variable was undefined in the browser, someone added the prefix, the error went away, and the key shipped. If the browser needs to call a paid API, the fix is a route on your own server that holds the key and calls the API on the user's behalf. That route also gives you somewhere to put a rate limit, which you are going to need anyway.\n\n## When you commit a key, and you will\n\nSuppose the key is now in a public repository. Someone deletes the line and commits the fix. Nothing has been fixed.\n\nThe old commit is still in the history. It is in every clone anyone made. It is in every fork. GitHub keeps unreachable commits addressable for a while. And most importantly: **automated scanners read the public events stream continuously.** Keys posted to public repositories are routinely used within minutes, without anyone cloning anything.\n\nThe only action that ends the exposure is **rotating the credential**. Go to the provider, revoke the old key, issue a new one, set it on the host.\n\nOrder matters more than people expect. Rotate first. Clean the history second, if at all. The common mistake is spending ninety minutes on `git filter-repo` while the live key stays live the entire time.\n\nIf it is a database password, rotate it. If it is a signing secret, rotate it and accept that every existing session is invalidated: that is the correct outcome, not a reason to delay.\n\n## Prevention that costs nothing\n\n- Turn on push protection or secret scanning if your host offers it. It refuses the push.\n- Run `gitleaks detect` in CI so a leak fails the build.\n- Never `console.log(config)`. Logs are copied into a search index, retained for thirty days, and readable by more people than the database is.\n- Give each environment its own key. Then a leak from staging is a staging problem.\n\n## The uncomfortable part\n\nEveryone does this eventually, experienced people included. The difference between a small incident and a real one is entirely how fast you rotate, and whether you had the reflex to rotate at all rather than to tidy."},"check":{"question":"A teammate pushed a database URL containing the password to a public repo. Twenty minutes later they noticed, removed the line, rewrote history with filter-repo, and force-pushed. The secret no longer appears anywhere on GitHub. Is the incident over?","options":[{"text":"No: the credential has to be rotated, because it was readable for twenty minutes and may already be in someone else's hands","correct":true},{"text":"Yes: the commit no longer exists on the remote, so there is nothing left for anyone to find","correct":false,"tempting":"Assumes the remote is the only copy, when clones, forks, proxy caches and scrapers all keep their own."},{"text":"Yes, as long as the repo has no forks and the traffic graph shows no clones during that window","correct":false,"tempting":"Believes exposure can be disproven from access data, when automated scanners read the public events stream without ever cloning."},{"text":"No, but only because the force-push will break collaborators' local branches and needs coordinating","correct":false,"tempting":"Correctly identifies a real cost of rewriting history and mistakes that inconvenience for the actual risk."}]},"prev":{"slug":"keeping-the-connection-open","title":"The handshake you keep paying for","url":"https://addaly.com/learn/cloud-and-shipping/keeping-the-connection-open"},"next":{"slug":"build-once-run-anywhere","title":"The same build, everywhere","url":"https://addaly.com/learn/cloud-and-shipping/build-once-run-anywhere"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'The key you must not commit', in Shipping It. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/cloud-and-shipping/secrets-and-env (Accessed: 31 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"The key you must not commit","courseTitle":"Shipping It","module":{"index":2,"slug":"making-a-deploy-boring","title":"Making a deploy boring"},"answer":null,"keyIdea":"A leaked key is only fixed by rotating it; scrubbing git history does not un-leak anything.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"configuration-that-changes-per-environment-does-not-belong-i","runs":[{"text":"Configuration that changes per environment does not belong in the file"}]},{"t":"p","runs":[{"text":"Your database URL is different on your laptop and in production. Your Stripe key is different in test and live. So they are not code. They are inputs."}]},{"t":"code","lang":"js","label":"JavaScript","text":"const key = process.env.OPENROUTER_API_KEY;\nif (!key) throw new Error(\"OPENROUTER_API_KEY is not set\");"},{"t":"p","runs":[{"text":"Check for the value "},{"text":"at startup, not at use","b":true},{"text":". A missing secret that crashes the process on boot is a failed deploy you notice in thirty seconds. A missing secret discovered by a 500 error on the one route that uses it is a Saturday."}]},{"t":"h2","id":"env-is-a-local-convenience-not-a-deployment-mechanism","runs":[{"text":".env is a local convenience, not a deployment mechanism"}]},{"t":"ul","items":[[{"text":".env","code":true},{"text":" goes in "},{"text":".gitignore","code":true},{"text":". Put it there before you create the file, not after."}],[{"text":".env.example","code":true},{"text":" gets committed, with the variable "},{"text":"names","b":true},{"text":" and no values. It is documentation."}],[{"text":"On the host, you set the real values through the platform:"}]]},{"t":"code","lang":"","label":"Text","text":"wrangler secret put BETTER_AUTH_SECRET\nfly secrets set DATABASE_URL=postgres://..."},{"t":"p","runs":[{"text":"Those live encrypted on the platform and are injected into the process. Nobody copies a "},{"text":".env","code":true},{"text":" to a server over SSH."}]},{"t":"h2","id":"anything-prefixed-for-the-browser-is-public","runs":[{"text":"Anything prefixed for the browser is public"}]},{"t":"p","runs":[{"text":"NEXT_PUBLIC_","code":true},{"text":", "},{"text":"VITE_","code":true},{"text":", "},{"text":"REACT_APP_","code":true},{"text":": these prefixes exist to tell the build tool “substitute this value into the JavaScript bundle”. That bundle is downloaded by every visitor. View source, search, done."}]},{"t":"p","runs":[{"text":"This is the most common leak I see, and it has a predictable cause: the build tool said the variable was undefined in the browser, someone added the prefix, the error went away, and the key shipped. If the browser needs to call a paid API, the fix is a route on your own server that holds the key and calls the API on the user’s behalf. That route also gives you somewhere to put a rate limit, which you are going to need anyway."}]},{"t":"h2","id":"when-you-commit-a-key-and-you-will","runs":[{"text":"When you commit a key, and you will"}]},{"t":"p","runs":[{"text":"Suppose the key is now in a public repository. Someone deletes the line and commits the fix. Nothing has been fixed."}]},{"t":"p","runs":[{"text":"The old commit is still in the history. It is in every clone anyone made. It is in every fork. GitHub keeps unreachable commits addressable for a while. And most importantly: "},{"text":"automated scanners read the public events stream continuously.","b":true},{"text":" Keys posted to public repositories are routinely used within minutes, without anyone cloning anything."}]},{"t":"p","runs":[{"text":"The only action that ends the exposure is "},{"text":"rotating the credential","b":true},{"text":". Go to the provider, revoke the old key, issue a new one, set it on the host."}]},{"t":"p","runs":[{"text":"Order matters more than people expect. Rotate first. Clean the history second, if at all. The common mistake is spending ninety minutes on "},{"text":"git filter-repo","code":true},{"text":" while the live key stays live the entire time."}]},{"t":"p","runs":[{"text":"If it is a database password, rotate it. If it is a signing secret, rotate it and accept that every existing session is invalidated: that is the correct outcome, not a reason to delay."}]},{"t":"h2","id":"prevention-that-costs-nothing","runs":[{"text":"Prevention that costs nothing"}]},{"t":"ul","items":[[{"text":"Turn on push protection or secret scanning if your host offers it. It refuses the push."}],[{"text":"Run "},{"text":"gitleaks detect","code":true},{"text":" in CI so a leak fails the build."}],[{"text":"Never "},{"text":"console.log(config)","code":true},{"text":". Logs are copied into a search index, retained for thirty days, and readable by more people than the database is."}],[{"text":"Give each environment its own key. Then a leak from staging is a staging problem."}]]},{"t":"h2","id":"the-uncomfortable-part","runs":[{"text":"The uncomfortable part"}]},{"t":"p","runs":[{"text":"Everyone does this eventually, experienced people included. The difference between a small incident and a real one is entirely how fast you rotate, and whether you had the reflex to rotate at all rather than to tidy."}]}],"check":{"question":[{"text":"A teammate pushed a database URL containing the password to a public repo. Twenty minutes later they noticed, removed the line, rewrote history with filter-repo, and force-pushed. The secret no longer appears anywhere on GitHub. Is the incident over?"}],"options":[{"text":[{"text":"No: the credential has to be rotated, because it was readable for twenty minutes and may already be in someone else’s hands"}],"correct":true,"tempting":null},{"text":[{"text":"Yes: the commit no longer exists on the remote, so there is nothing left for anyone to find"}],"correct":false,"tempting":[{"text":"Assumes the remote is the only copy, when clones, forks, proxy caches and scrapers all keep their own."}]},{"text":[{"text":"Yes, as long as the repo has no forks and the traffic graph shows no clones during that window"}],"correct":false,"tempting":[{"text":"Believes exposure can be disproven from access data, when automated scanners read the public events stream without ever cloning."}]},{"text":[{"text":"No, but only because the force-push will break collaborators’ local branches and needs coordinating"}],"correct":false,"tempting":[{"text":"Correctly identifies a real cost of rewriting history and mistakes that inconvenience for the actual risk."}]}],"order":[3,0,2,1],"why":null},"prev":{"slug":"keeping-the-connection-open","title":"The handshake you keep paying for","minutes":9},"next":{"slug":"build-once-run-anywhere","title":"The same build, everywhere","minutes":9}}}