Accidentally Pushed .env File to GitHub? Fix It in This Order
Accidentally pushed .env file to GitHub? Rotate every key in it before you touch git. Deleting the file, or even the commit, doesn't take the keys back: anyone who cloned, forked or scraped the repository while it was up still has them. After that, check each provider for activity you didn't cause, stop tracking the file, and only then decide whether rewriting history is worth it.
Accidentally pushed .env file to GitHub: what to do first
Treat every value in the file as public. Revoke or rotate each secret at its provider, deploy the new values, and confirm the old ones stop working. Then read each provider's logs for anything you didn't do. Removing the file from the repository comes after that, because git keeps the old copy in history.
Here is the whole sequence. The order matters more than speed on any single step.
- Rotate or revoke every secret in the file.
- Check each provider's logs and usage for activity that wasn't you.
- Stop tracking
.env, ignore it, and commit a.env.exampleinstead. - Optionally, rewrite history with git-filter-repo, knowing its limits.
- Turn on push protection and a pre-commit secret scan so it doesn't happen again.
Step 1: Rotate or revoke every key in the file
GitHub's guide to removing sensitive data from a repository is blunt about the order. If the data is a secret, "as a first step you need to revoke and/or rotate that secret." It adds that once the secret is revoked or rotated, it can no longer be used for access, and that may be enough to solve your problem.
The reason is simple. A commit you delete or rewrite on GitHub may still be reachable in other people's clones and forks, through pull requests that reference it, and by its commit hash in cached views. You can't recall a key from all of those places. You can make it worthless.
If the repository is public, assume the key has already been copied. If it's private and only you have access, the urgency is lower, but rotate anyway: the file now sits in every clone and in the history of every branch that contains that commit.
Go through the file line by line. For each secret, create a replacement, update it wherever the app runs (your host's environment variables, CI secrets, Edge Function secrets), redeploy, and then delete or expire the old one.
Where to rotate the keys a vibe-coded app usually has
| Key in your .env | What it looks like | Where to rotate it |
|---|---|---|
Supabase secret key or legacy service_role key | sb_secret_..., or a long JWT starting eyJ | Dashboard, Settings > API Keys: create a new secret key, swap it in everywhere, then delete the old one. For legacy keys, deactivate them in the same section. Supabase docs |
| Stripe secret or restricted key | sk_live_..., rk_live_... | Dashboard, API keys page: the ⋯ menu on the key, Rotate key, and set Expiration to Now for a leaked key. Stripe docs |
| Stripe webhook signing secret | whsec_... | Webhooks tab in Workbench: open the endpoint, ⋯ menu, Roll secret. Stripe docs |
| OpenAI API key | sk-... or sk-proj-... | API keys page in the OpenAI platform: create a new key, deploy it, revoke the old one. OpenAI docs |
| Anthropic API key | sk-ant-... | Claude Console, API keys page: the ⋯ menu on the key, Delete API Key, then Create Key. Anthropic help |
| Resend API key | re_... | API keys page: More options, Remove API key, then deploy a replacement. Resend docs |
| Database URL | postgresql://user:password@host:5432/db | The password is the secret. On Supabase, reset it under Database > Settings. On other hosts, change that database user's password. Supabase docs |
| Supabase anon or publishable key | sb_publishable_..., or a JWT starting eyJ | Public by design, so it isn't the emergency. What it can reach depends on your RLS policies. See is the Supabase anon key safe. |
A few details that trip people up:
- Stripe lets the old key keep working for up to 7 days after a rotation, unless you choose Now. For a leaked key, choose Now and accept a few minutes of errors while you deploy the new one.
- Supabase's secret key bypasses Row Level Security entirely, which makes it the most dangerous line in most of these files. The Supabase service role key post covers where it can and can't live.
- Values with a
NEXT_PUBLIC_,VITE_orEXPO_PUBLIC_prefix are written into your JavaScript bundle at build time. After you change one, rebuild and redeploy, or the old value keeps shipping. - If a secret ever had one of those prefixes, it was public before you pushed anything. How to hide an API key in the frontend explains the fix.
Step 2: Check each provider's logs for anything you didn't do
Rotation stops future use. It doesn't tell you what already happened. Spend ten minutes per provider:
- Stripe: on the API keys page, the ⋯ menu on a key has View request logs. Look for requests you don't recognize, and for refunds, payouts or webhook endpoints you didn't create.
- OpenAI and Anthropic: open the usage pages and look for spend or request spikes after the time of the push, and for keys or projects you didn't create.
- Supabase: read the project's API and database logs for traffic you don't recognize, and look for rows, users or tables you didn't create.
- Resend: the Logs page lists every API request and can be filtered by API key; the Emails page shows who was sent what.
- Database: look for unfamiliar roles, tables or functions, and for deleted or changed rows.
If you find something, write down what you saw and when, and contact that provider's support.
Step 3: How to remove .env file from GitHub repo (stop tracking it)
Now take the file out of the repository going forward. git rm --cached removes the file from git's index but leaves your local copy alone:
git rm --cached .env
echo ".env" >> .gitignore
git add .gitignore
git commit -m "Stop tracking .env"
git pushIf you have other env files, ignore them as a group and keep the template visible:
.env
.env.*
!.env.exampleThen commit a .env.example with the names and no values, so the next person (or the next AI session) knows what to fill in:
# .env.example: copy to .env and fill in. Never commit real values.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
SUPABASE_SECRET_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
OPENAI_API_KEY=
DATABASE_URL=This stops tracking the file from now on. Every older commit still contains it, which is why step 1 came first.
Should I upload env file to GitHub?
No. The Next.js environment variables guide puts it plainly: the default create-next-app template adds .env files to .gitignore, and "you almost never want to commit these files to your repository." Real values belong in your host's environment settings. The repository gets .env.example.
How to hide .env file in GitHub
Adding .env to .gitignore hides it from future git add commands, but .gitignore doesn't affect a file git is already tracking. That's why the git rm --cached step comes first. After it, git status should no longer list .env, and the file won't appear on GitHub in new commits.
Step 4: How to remove secrets from GitHub history (optional)
Rewriting history is optional, and it matters less than rotation. GitHub's secret scanning docs say the same: rotate the credential immediately, and removing it from history is time-intensive and often unnecessary once it's revoked. Do it if the file held something you can't rotate, such as personal data, or if you want the repository clean before making it public.
GitHub's guide uses git-filter-repo, version 2.47 or later, for its --sensitive-data-removal flag. Work in a fresh clone, because the rewrite can discard local changes you haven't pushed:
brew install git-filter-repo
git clone https://github.com/YOUR-USERNAME/YOUR-REPOSITORY
cd YOUR-REPOSITORY
git-filter-repo --sensitive-data-removal --invert-paths --path .env--path takes the file's path in the repository, not just its name. If the file ever lived somewhere else, add another --path for that location.
Check that it's gone, then count the pull requests the rewrite touches:
git log --all --name-status -- .env
grep -c '^refs/pull/.*/head$' .git/filter-repo/changed-refsThe first command should print nothing. When you're satisfied, force-push every branch and tag:
git push --force --mirror originGitHub marks refs/pull/ refs as read-only, so those fail to push, and that's expected. If any other ref fails, branch protection is probably blocking force pushes; turn it off for the push and turn it back on afterward. Once you push, the rewrite can't be undone.
What a rewrite doesn't fix
GitHub lists the side effects, and they're why rotation comes first:
- Clones and forks keep the old commits. Anyone with a clone from before the rewrite can bring the file back with a plain
git pullandgit push. Collaborators need to re-clone, or rebase (not merge) their branches. If a fork contains the commit, you have to ask its owner to clean it up or delete it, and GitHub can't give you their contact details. - Pull requests and cached views still point at it. Commits stay reachable through pull requests that reference them and by their hash in cached views. Only GitHub Support can remove those. Send them the repository name, the number of affected pull requests, and the "First Changed Commit(s)" that git-filter-repo printed. GitHub says Support only helps with sensitive data when rotating the credentials can't mitigate the risk.
- Every commit hash after the leak changes. Diffs of closed pull requests break, comments on open ones can be lost, and signatures on commits and tags are removed.
Remove secret from git history inside another file
If the key was pasted into a source file rather than a separate .env, replace the string everywhere instead of deleting a file. Put each leaked value on its own line in a file outside the repository, then run:
git-filter-repo --sensitive-data-removal --replace-text ../passwords.txtEach match becomes ***REMOVED***. Confirm with git log -S"the-leaked-value" --all -p, which should find nothing.
Step 5: Stop it happening again
Push protection. GitHub's push protection blocks a push that contains a recognized secret before it reaches the repository. Push protection for your own account is on by default, but it only covers public repositories. To protect a repository for everyone who pushes to it, open its Settings, go to Advanced Security, enable Secret Protection, then enable Push protection (how to enable it). It's free on public repositories. For private repositories it needs GitHub Secret Protection on an organization plan, so a private repo on a personal account usually can't use it. That's where the next layer matters.
A pre-commit secret scan. A hook that runs before every commit catches the file on your machine, whatever plan you're on. gitleaks is a common open-source choice. With the pre-commit framework installed, add .pre-commit-config.yaml:
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaksThen run pre-commit install once per clone. The hook scans what you've staged and blocks the commit if it sees a secret.
Habits that help. Keep .env.example up to date. Stage files by name instead of git add ., and read git diff --cached before you commit. If an AI assistant sets up files for you, check what it staged before you push; a .env it created to get something working is easy to miss. The vibe coding security checklist covers the rest of the prelaunch pass.
Is the key still in an older commit?
Most people find this out by accident: the file was deleted weeks ago, but the commit that added it is still in history. Check now:
git log --all --oneline -- .envAny output means some commit still contains the file. To search every commit for known key formats, gitleaks git -v scans the whole history of the repository you run it in.
If you'd rather not do that repository by repository, a free Tuvio scan needs no account and reads your git history (the full history where it can, and the report says how far back it went), so a key format it recognizes still gets flagged even if a later commit deleted the file. It won't flag a database connection string, so check your DATABASE_URL by hand. The example report shows what a secrets finding looks like.