DEV Community

Cover image for Stop Pushing Secrets to GitHub: Protect Your API Keys Before They Leak
𝗝𝗼𝗡𝗻
𝗝𝗼𝗡𝗻

Posted on AI-assisted

Stop Pushing Secrets to GitHub: Protect Your API Keys Before They Leak

You notice the mistake a few seconds after pushing:

.env
config.ts
service-account.json
Enter fullscreen mode Exit fullscreen mode

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.

If you just pushed a real secret

  1. Revoke or rotate the credential immediately.
  2. Check the provider's access and billing logs.
  3. Identify every location where the secret appeared.
  4. Replace it with a newly issued credential stored outside the repository.
  5. Decide whether Git history must also be rewritten.
  6. Add preventive controls before continuing normal work.

Table of contents


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=
Enter fullscreen mode Exit fullscreen mode

This is not:

PAYMENT_API_KEY=live_example_value
DATABASE_URL=postgres://user:real-password@host/database
EMAIL_PROVIDER_TOKEN=real_token_value
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Rotation limits access. History rewriting removes stored copies.

These are different operations, and rotation usually comes first.

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
Enter fullscreen mode Exit fullscreen mode

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.

When is history rewriting worth considering?

Consider it when:

  • the exposed data cannot be revoked,
  • the repository contains a private key or sensitive personal data,
  • policy requires removal from all reachable history,
  • the value appears across many commits or branches,
  • the repository owner has assessed the operational impact,
  • collaborators can coordinate replacement or cleanup of clones.

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.

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
Enter fullscreen mode Exit fullscreen mode

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($|\.)'
Enter fullscreen mode Exit fullscreen mode

To stop tracking the current file while keeping the local copy:

git rm --cached .env
Enter fullscreen mode Exit fullscreen mode

Then commit the index change and the updated ignore rule:

git add .gitignore
git commit -m "Stop tracking local environment files"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
git diff --cached
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=
Enter fullscreen mode Exit fullscreen mode

Then document setup:

cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Push protection recognizes supported patterns. It is an important safety net, not proof that a repository contains no secrets.

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
Enter fullscreen mode Exit fullscreen mode

Use a GitHub Actions secret:

- name: Deploy
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
  run: ./deploy.sh
Enter fullscreen mode Exit fullscreen mode

Create the secret in:

Repository settings
β†’ Secrets and variables
β†’ Actions
β†’ New repository secret
Enter fullscreen mode Exit fullscreen mode

Or use GitHub CLI:

gh secret set DEPLOY_TOKEN
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

Use a secret for sensitive data:

env:
  DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

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_TOKEN permissions,
  • required reviewers for sensitive environments.

A minimal workflow permission block might look like:

permissions:
  contents: read
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
  • [ ] .env and local credential files are ignored.
  • [ ] .env.example contains names and safe placeholders only.
  • [ ] git diff --cached is 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_TOKEN permissions 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
Enter fullscreen mode Exit fullscreen mode

The strongest setup is also layered:

  • .gitignore keeps ordinary local files out of new commits,
  • .env.example documents 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


Connect with Me

If you found this article helpful, let's connect!

Top comments (0)