You notice the mistake a few seconds after pushing:
.env
config.ts
service-account.json
One of those files contains a real credential.
Your first instinct may be to delete the value, create another commit, and push again.
Do not make that your first response.
The credential may already exist in Git history, a clone, a fork, a pull request, an automated log, or a security alert. Removing it from the latest version of a file does not invalidate it.
If a real secret reaches GitHub, assume it has been exposed.
Revoke or rotate it first. Investigate the exposure second. Clean the repository only when necessary. Then add controls that make the same mistake harder to repeat.
Table of contents
- What counts as a secret?
- The correct recovery order
- Why deleting the file is not enough
- Check what Git is already tracking
- Use
.gitignorecorrectly - Commit
.env.example, not.env - Enable GitHub's protection
- Store secrets safely in GitHub Actions
- Add local checks before every push
- Use the final checklist
What counts as a secret?
A secret is any value that grants access or proves identity and should not be available to everyone who can read the repository.
Common examples include:
- API keys,
- personal access tokens,
- OAuth client secrets,
- database passwords,
- private SSH keys,
- cloud access credentials,
- webhook signing secrets,
- service-account credentials,
- package registry tokens,
- session-signing keys,
- production connection strings.
Not every configuration value is a secret. A public API base URL, feature flag name, region, or build mode can usually be stored as ordinary configuration.
A simple test is:
If someone copied this value from a public repository, could that person access data, impersonate a service, trigger an action, or create a cost?
If the answer might be yes, treat the value as sensitive.
A secret name is not a secret
This is safe to commit:
PAYMENT_API_KEY=
DATABASE_URL=
EMAIL_PROVIDER_TOKEN=
This is not:
PAYMENT_API_KEY=live_example_value
DATABASE_URL=postgres://user:real-password@host/database
EMAIL_PROVIDER_TOKEN=real_token_value
The first file documents the required configuration. The second file contains credentials that can be used.
The correct recovery order
When a secret is exposed, sequence matters.
Revoke or rotate
β
Assess the exposure
β
Replace the credential
β
Remove it from current code
β
Decide whether history cleanup is necessary
β
Add preventive controls
1. Revoke or rotate the credential
Go to the service that issued the credential and make the exposed value unusable.
Depending on the provider, this may mean:
- deleting the key,
- rotating the key,
- revoking a token,
- resetting a password,
- disabling a service account,
- replacing a signing secret.
If the service supports overlapping credentials, create a replacement, update the application, verify the new credential, and then revoke the exposed one. If immediate abuse is possible, revoke first and accept the temporary interruption.
GitHub's remediation guidance says leaked secrets should be treated as compromised and revoked or rotated. Deleting the value from the current code is not considered sufficient remediation.
2. Check the provider's logs
Look for unexpected activity after the earliest possible exposure time:
- unfamiliar IP addresses,
- unusual API calls,
- new resources,
- unexpected downloads,
- permission changes,
- increased usage,
- billing spikes.
The secret provider is the strongest source of truth for whether a credential remains valid and how it was used.
3. Find every exposed location
The value may appear in more places than the file you first noticed:
- older commits,
- another branch,
- an open or closed pull request,
- issue text or comments,
- workflow logs,
- generated artifacts,
- documentation,
- copied sample files,
- forks or local clones.
GitHub secret scanning examines Git history and can also scan other GitHub surfaces such as issues, pull requests, discussions, wikis, and secret gists for supported patterns.
4. Replace the credential safely
Store the replacement in an appropriate secret manager, deployment platform, local environment, or GitHub Actions secret. Do not paste the replacement into the same tracked file.
5. Decide whether history cleanup is necessary
Rotating the credential removes its ability to grant access. Rewriting history serves a different purpose: removing the sensitive value from repository history.
History cleanup may be justified when the exposed material is not easily revocable, contains private data, or must be removed for policy or legal reasons. It is not automatically the first or safest step for every leaked API key.
Why deleting the file is not enough
Git stores snapshots through commits. If a secret appears in one commit and is deleted in the next, the earlier commit still contains it.
This sequence does not solve the incident:
git rm .env
git commit -m "Remove environment file"
git push
It removes .env from the latest version of the branch, but the exposed credential remains in previous history and is still usable until revoked.
Deleting and recreating the repository is not a substitute for revoking the credential either. Copies may already exist elsewhere.
Why history rewriting requires care
Tools such as git-filter-repo can remove sensitive paths or values from history, but rewriting shared history has side effects:
- commit hashes change,
- collaborators must clean or replace existing clones,
- outdated clones can accidentally restore the secret,
- pull request diffs and comments can be disrupted,
- branch protections may need temporary changes,
- automation that depends on commit hashes may break.
GitHub explicitly recommends coordinating with collaborators and understanding these consequences before rewriting history.
Do not paste a destructive history-rewrite command from a random snippet and run it against an active repository. Read GitHub's current procedure, back up what must be preserved, coordinate the maintenance window, and verify every branch and tag afterward.
Consider it when: Rotation may be enough for a standard API token when the old value is definitely invalid and no separate policy requires complete removal. Make that decision based on the type of data, repository visibility, exposure, and organizational requirements.When is history rewriting worth considering?
Check what Git is already tracking
A common misunderstanding is that adding a filename to .gitignore makes Git forget it.
It does not.
.gitignore prevents matching untracked files from being added by ordinary commands. It does not stop Git from tracking a file that is already in the index.
Check whether .env is tracked:
git ls-files --error-unmatch .env
If Git prints .env, the file is tracked. If it returns an error, the exact path is not tracked in the current index.
You can search for a wider group of environment files:
git ls-files | grep -E '(^|/)\.env($|\.)'
To stop tracking the current file while keeping the local copy:
git rm --cached .env
Then commit the index change and the updated ignore rule:
git add .gitignore
git commit -m "Stop tracking local environment files"
This is a repository-hygiene step. It does not revoke a leaked credential or erase older commits.
Check the staged snapshot before committing
Before every sensitive commit, inspect what is about to become part of history:
git status --short
git diff --cached
The second command shows the staged diff. This catches many accidental additions before they become commits.
If the staged diff is large, do not skim it blindly. Split unrelated work into smaller commits so that unexpected configuration files and credentials are easier to notice.
Use .gitignore correctly
A sensible starting point for a repository that uses local environment files is:
# Local environment files
.env
.env.*
!.env.example
# Local credentials and certificates
*.pem
*.key
service-account*.json
# Tool-specific local configuration
.envrc
Do not copy this block without adapting it.
For example, a project might intentionally track a public test certificate, a harmless fixture named service-account.example.json, or a platform-specific environment template. Ignore rules are repository policy, not universal truth.
Ignore by purpose, not by panic
Avoid broad rules such as:
*.json
*.yaml
Those rules can hide legitimate source files. A good .gitignore excludes local or generated data without making important project files invisible.
Verify the matching rule
If Git ignores a file and you do not know why, run:
git check-ignore -v .env.local
Git will show the ignore file and rule responsible for the match.
Remember global ignore files
A developer may also have a global Git ignore file. That can be useful for editor files and system artifacts, but repository-critical safety rules should still live in the repository's own .gitignore so every contributor receives them.
Commit .env.example, not .env
A project needs to document its required configuration without publishing real values.
Create .env.example:
# Application
APP_ENV=development
APP_PORT=3000
# Database
DATABASE_URL=
# External services
PAYMENT_API_KEY=
EMAIL_PROVIDER_TOKEN=
# Session security
SESSION_SECRET=
Then document setup:
cp .env.example .env
The developer fills .env locally or retrieves values from the team's approved secret manager.
Use obviously fake examples
Avoid realistic-looking placeholder credentials. A detector may flag them, and a contributor may not know whether they are safe.
Prefer:
PAYMENT_API_KEY=replace_with_local_value
or leave the value blank.
Do not copy a real key and alter only the last few characters. Even a partially exposed credential may reveal information or be reconstructed incorrectly in logs and screenshots.
Fail clearly when required configuration is missing
A missing variable should cause an understandable startup error rather than a mysterious failure later.
const requiredVariables = [
"DATABASE_URL",
"PAYMENT_API_KEY",
"SESSION_SECRET",
];
for (const name of requiredVariables) {
if (!process.env[name]) {
throw new Error(`Missing required environment variable: ${name}`);
}
}
This validates presence, not safety. It cannot determine whether a value has been committed elsewhere or granted excessive permissions.
What is safe to commit?
Usually safe
- variable names,
- empty placeholders,
- public URLs,
- non-sensitive feature flags,
- documented local defaults,
- sample configuration using fake values.
Usually unsafe
- live API keys,
- access and refresh tokens,
- private keys,
- production database URLs with credentials,
- cloud credentials,
- signing secrets,
- passwords,
- complete service-account files.
Context dependent
- public client identifiers,
- analytics IDs,
- test credentials,
- webhook URLs,
- certificate files,
- internal hostnames.
A value being visible in frontend code does not automatically make it harmless. Check the provider's security model and intended usage.
Enable GitHub's protection
GitHub provides two related defenses: secret scanning and push protection.
Secret scanning
Secret scanning searches for supported credentials in repository history and other supported GitHub content. Public repositories receive secret scanning automatically for free. Availability for private and internal repositories depends on repository ownership, plan, and GitHub Secret Protection settings.
When an alert identifies a real credential, rotate the credential immediately. A scanner can find an exposure, but it cannot undo access that has already occurred.
Push protection
Push protection attempts to stop supported secrets before they reach the repository.
GitHub documents coverage for several entry paths, including:
- command-line pushes,
- commits made through the GitHub UI,
- file uploads,
- REST API requests,
- certain GitHub MCP server interactions for public repositories.
User push protection is enabled by default on GitHub.com and protects pushes of supported secrets to public repositories. Repository-level protection provides broader administrative controls and depends on GitHub Secret Protection.
If GitHub blocks your push
Do not automatically bypass the warning.
Read the detected secret type and every location listed in the error. If the value is real, remove it from all affected commits before pushing again.
If the secret was introduced in the latest local commit:
# Remove the secret from the file first, then stage the correction
git add path/to/file
git commit --amend --no-edit
git push
Amending changes the commit that introduced the secret instead of adding a second commit that still leaves the original one in the branch history.
If the secret appears in earlier local commits, resolving the block may require an interactive rebase. Follow GitHub's current instructions and create a backup branch before changing local history.
Bypass only when you have verified that the value is a false positive, a documented test value, or another value explicitly safe to publish. βI will fix it laterβ is not a safe default for a live credential.
Store secrets safely in GitHub Actions
Workflow files are committed to the repository, so never place a credential directly in YAML.
Wrong:
- name: Deploy
env:
DEPLOY_TOKEN: live_token_value
run: ./deploy.sh
Use a GitHub Actions secret:
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./deploy.sh
Create the secret in:
Repository settings
β Secrets and variables
β Actions
β New repository secret
Or use GitHub CLI:
gh secret set DEPLOY_TOKEN
GitHub supports secrets at repository, environment, and organization scope. Choose the narrowest scope that matches the workflow.
Secrets and variables are not interchangeable
Use a configuration variable for non-sensitive data:
env:
DEPLOY_REGION: ${{ vars.DEPLOY_REGION }}
Use a secret for sensitive data:
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
GitHub warns that variables are not masked in build output by default. Sensitive values belong in secrets.
Apply least privilege
A deployment credential should not automatically have administrator access to every repository or environment.
Prefer:
- short-lived credentials where available,
- read-only access when write access is unnecessary,
- environment secrets for protected deployments,
- fine-grained tokens over broad personal tokens,
- explicit
GITHUB_TOKENpermissions, - required reviewers for sensitive environments.
A minimal workflow permission block might look like:
permissions:
contents: read
Increase permissions only for the job that needs them.
Do not rely completely on log redaction
GitHub masks many known secret values, but transformations, structured values, generated tokens, and accidental output can still create risk.
Avoid commands such as:
echo "$DEPLOY_TOKEN"
Do not place JSON or YAML blobs into one secret if individual values can be stored separately. GitHub notes that structured secret values can be harder to redact reliably because masking often depends on exact matches.
If an unredacted credential reaches a workflow log, delete the affected log and rotate the credential.
Add local checks before every push
Remote protection is valuable, but earlier feedback is better.
A local secret scanner can inspect staged content or commits before they leave the workstation. Common open-source options include tools such as Gitleaks and TruffleHog. Select a tool based on your language ecosystem, CI environment, supported patterns, maintenance policy, and false-positive handling.
The workflow matters more than the brand:
Edit
β
Review staged diff
β
Run local secret scan
β
Commit
β
Run CI scan
β
Push protection
β
Repository secret scanning
No layer is complete on its own.
Add a pre-commit check carefully
A local hook can scan staged changes, but hooks stored only under .git/hooks are not automatically shared with every clone. If the team depends on a hook, manage it through a documented tool or repository setup process.
The hook should:
- scan only relevant staged content for speed,
- show the file and rule that triggered,
- provide a documented false-positive process,
- fail safely,
- avoid uploading source code to an unapproved service.
Repeat the check in CI
Local hooks can be skipped or misconfigured. CI provides a consistent second boundary for pull requests and protected branches.
A failing CI scan should explain how to remediate the finding. Security gates that only display βfailedβ encourage bypasses rather than safe behavior.
Avoid these common mistakes
βIt is private, so the key is safeβ
Private repositories reduce exposure but do not make hardcoded credentials appropriate. Access can expand, logs can leak, repositories can change visibility, and credentials often outlive the code that contains them.
βBase64 hides the valueβ
Base64 is encoding, not encryption. Anyone with the string can decode it.
βThe key is only for testingβ
Test credentials can still access shared data, trigger paid services, or become production credentials later. Use clearly scoped, disposable values with strict limits.
βI deleted the repositoryβ
Deletion does not revoke the credential or delete existing copies.
β.gitignore protects everythingβ
.gitignore does not remove tracked files, scan arbitrary content, prevent manual force-adds, or invalidate exposed credentials.
βPush protection will catch every secretβ
Detectors cover many known and generic patterns, but no scanner can identify every custom password, encoded value, or context-specific credential.
Final repository security checklist
Local development
- [ ] Real secrets are stored outside tracked files.
- [ ]
.envand local credential files are ignored. - [ ]
.env.examplecontains names and safe placeholders only. - [ ]
git diff --cachedis reviewed before sensitive commits. - [ ] Required configuration fails with a clear startup error.
- [ ] Local scanning is documented and repeatable.
GitHub repository
- [ ] Repository visibility is intentional.
- [ ] Secret scanning availability and alerts have been reviewed.
- [ ] Push protection is enabled where available.
- [ ] Bypass permissions are limited and monitored.
- [ ] Security alerts have a named owner.
- [ ] Contributors know how to report an accidental exposure.
GitHub Actions
- [ ] Sensitive values use Actions secrets, not workflow literals.
- [ ] Non-sensitive configuration uses variables.
- [ ]
GITHUB_TOKENpermissions are minimal. - [ ] Deployment secrets use protected environments where appropriate.
- [ ] Workflows do not print secrets or transformed secret values.
- [ ] Third-party actions are reviewed and pinned according to team policy.
Incident response
- [ ] The exposed credential is revoked or rotated first.
- [ ] Provider logs and billing are reviewed.
- [ ] Every known location is identified.
- [ ] History rewriting is treated as a coordinated operation.
- [ ] Existing clones are addressed after a rewrite.
- [ ] Preventive controls are added after the incident.
The takeaway
The most dangerous misconception is that removing a key from the latest file removes the exposure.
It does not.
A safer response is straightforward:
Assume exposure
β
Revoke or rotate
β
Check usage
β
Replace securely
β
Clean current code
β
Evaluate history cleanup
β
Strengthen prevention
The strongest setup is also layered:
-
.gitignorekeeps ordinary local files out of new commits, -
.env.exampledocuments configuration safely, - staged-diff review catches mistakes before commit,
- local and CI scanning provide early feedback,
- push protection blocks many supported secrets,
- secret scanning finds supported exposures,
- least-privilege credentials reduce the impact of a mistake.
None of these controls replaces the others.
If you discover a live secret in a repository, do not spend the first minutes trying to make the commit disappear. Make the credential useless first.
What is the most useful secret-protection check you have added to a project?
Read GitHub's secret remediation guide
Sources and further reading
- GitHub Docs: Remediating a leaked secret in your repository
- GitHub Docs: Removing sensitive data from a repository
- GitHub Docs: Push protection
- GitHub Docs: Working with push protection from the command line
- GitHub Docs: Secret scanning
- GitHub Docs: Using secrets in GitHub Actions
- GitHub Docs: Secure use reference for GitHub Actions
Connect with Me
If you found this article helpful, let's connect!
- π» GitHub: johnnylemonny
Top comments (0)