Treat any secret that reached a shared place as compromised and revoke or rotate it at the issuer first; deleting the file or commit does not stop anyone who already copied it. Then end sessions and tokens minted from it, check the provider's audit logs from the moment of exposure, remove the value from code, history, logs and artifacts, and replace it with a short-lived or vault-managed credential. A history rewrite never replaces rotation.
- Revoke or rotate at the issuer before touching git history; a short outage beats a working stolen key.
- End derived access too: AWS session credentials, Google Cloud access tokens and Entra tokens can outlive the key.
- Search audit logs by the credential's own ID, from the first exposure, in every Region, for use and persistence.
- Don't treat a history rewrite as remediation: clones, forks, caches and pull request refs keep copies.
- Turn on push protection and a pre-commit scanner, then prove both block a test secret.
- Prefer federated, short-lived credentials so the next leak expires on its own.
| Setting | Value | Note | Source |
|---|---|---|---|
| AWS session credentials minted from a leaked access key | Stay valid until they expire: up to 36 hours (default 12) from issue | Deny them with an aws:TokenIssueTime condition, or delete the IAM user. | https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_control-access_disable-perms.html |
| AWS CloudTrail event history | 90 days of management events, per Region | IAM and global STS events are recorded in us-east-1. S3 object reads appear only if a trail logged data events. | https://docs.aws.amazon.com/awscloudtrail/latest/userguide/view-cloudtrail-events-cli.html |
| Google Cloud access tokens minted from a service account key | 1 hour by default, up to 12 hours if extended | Disabling or deleting the key does not revoke them; disabling the service account does. | https://cloud.google.com/iam/docs/keys-disable-enable |
| Microsoft Entra access tokens | 60–90 minutes by default | CAE tokens for Microsoft Graph last up to 24 hours but are revoked when the service principal is disabled. | https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens |
| GitHub App installation tokens | 1 hour | App private keys never expire; generate a new key before you can delete the leaked one. | https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps |
| Stripe key rotation grace period | Old key can keep working for up to 7 days | For an exposed key choose expiration Now. | https://docs.stripe.com/keys#rotate-an-api-key-in-the-dashboard |
| git-filter-repo | 2.47 or later | Required for --sensitive-data-removal, which GitHub's procedure uses. | https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository |
| Publicly trusted TLS certificate lifetime | 200 days maximum for certificates issued from 15 March 2026 | 100 days from 15 March 2027 and 47 days from 15 March 2029 (CA/Browser Forum Baseline Requirements 6.3.2). | https://github.com/cabforum/servercert/blob/main/docs/BR.md |
- From an isolated machine, call the issuer with the old credential and expect an authentication failure; call it with the replacement and expect success.
- Query the audit log for the old key ID, and for any session key IDs it minted, after the revocation time; expect nothing but denied attempts.
- Scan a fresh clone of each affected repository with
gitleaks gitand expect exit code 0; check CI logs, container images and packages that could hold the value. - Commit a dummy key on a throwaway branch and confirm that both the pre-commit hook and push protection block it.
- List every identity, key, role, OAuth app, deploy key, webhook and scheduled job created or changed during the exposure window, and confirm each is explained or removed.
- Applies to
- git-filter-repo 2.47 or later (tested 2.47.0)
- Gitleaks 8.19 or later for the git and dir commands (tested 8.30.1)
- OpenSSH RevokedKeys with a KRL (tested 10.3)
- AWS CLI Commands validated with aws-cli 1.46; identical in v2
API keys, tokens, cloud credentials and private keys exposed in code, logs or messages. Deleting a secret from history does not revoke it.
- Part of
- Workflows: Fix a finding, Respond to a vulnerability
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Order containment so the issuer rejects the secret before cleanup starts.
- Identify sessions and tokens that outlive a revoked credential.
- Scope use and persistence from provider logs, including their blind spots.
- Purge git history with git-filter-repo and explain why rotation is still required.
Before you start
Developers, on-call engineers and AppSec staff who may be the first to see a leaked-credential alert
- Use git from the command line, including clone and push.
- Read cloud audit log entries such as CloudTrail events or Entra sign-ins.
- For the exercise: nothing to install; the event timeline is supplied.
Treat a secret as compromised as soon as it reaches any place other people or systems can read: a public or private repository, a CI log, a container image, a published package, a chat message, a ticket or a paste site. Attackers scan public code for keys continuously, and GitHub forwards tokens it finds in public repositories to the providers that issued them. Deleting the file or the commit removes your copy, not anyone else's.
Only the issuer can make the value useless, so revoke, deactivate or rotate it there first, using an administrator identity rather than the leaked credential. Accept a short outage if consumers break: a failed deployment costs less than an attacker holding working credentials. Then work through the rest in this order.
- Contain: revoke or deactivate the credential at the issuer.
- Cut derived access: end the sessions and tokens that were minted with it and outlive it.
- Replace: issue a new credential with a tighter scope, ideally short-lived, and deploy it to legitimate consumers.
- Assess: read the provider's audit logs from the start of the exposure window, looking for use and persistence.
- Clean up: remove the value from code, history, logs, images and packages, and fix the path that leaked it.
- Record: keep a timeline of exposure, detection, revocation and the last suspicious event.
Identify the issuer, the account or project, the identity behind the secret and its permissions. Note whether it can mint other credentials: an AWS access key can call sts:GetSessionToken, a Google Cloud service account key can request access tokens, and a GitHub App private key can create installation tokens. A read-only analytics key and an organization administrator key need very different follow-up.
Set the exposure window from evidence. It starts when the value first became readable by someone else: for git, the push time in the hosting provider's activity or audit log, not the commit dates, which the committer can set to anything; for a public repository, also the moment the repository or a fork became public. It ends when the issuer rejects the value. Everything inside that window is in scope.
Look for copies beyond the one in the alert. The same value often sits in an .env file in another repository, a CI variable printed by a debug step, a container image layer, a compiled front-end or mobile bundle, crash reports and old forks. Scan them with the same tool and treat every hit as part of the same incident. When the copy is a file the web server serves, such as /.env or a .git folder, also stop serving it (/domains/appsec/secure-coding/information-disclosure-misconfiguration/).
When the leak is at a provider that stores your secrets, the scope is everything stored there. CircleCI's January 2023 breach meant every customer had to rotate every stored secret (/domains/runtime/cloud-posture/case-study-circleci-2023/); a SAS token published in a repository, as in Microsoft's 2023 exposure, is scoped by what the token encodes (/domains/runtime/cloud-posture/case-study-microsoft-sas-token/).
Run these with an administrator identity and current CLIs, and rehearse them before you need them. Each row lists the containing action, where use shows up, and the trap that most often leaves access open.
| Credential | Contain | Check for use | Watch out for |
|---|---|---|---|
AWS IAM access key (AKIA...) | aws iam update-access-key --status Inactive, then attach a deny on aws:TokenIssueTime to the user. Delete the key once nothing legitimate needs it. | CloudTrail lookup-events by AccessKeyId in every Region, plus the GetSessionToken and AssumeRole calls it made and the temporary key IDs they returned. | Session credentials survive deactivation for up to 36 hours. If AWS attached AWSCompromisedKeyQuarantineV3, keep it and follow the support case: it denies listed high-risk actions, not everything. |
| Google Cloud service account key (JSON) | gcloud iam service-accounts keys disable KEY_ID --iam-account=SA_EMAIL, then delete it. Disable the service account itself to stop access tokens already minted. | Cloud Audit Logs filtered on protoPayload.authenticationInfo.serviceAccountKeyName. | Data Access logs are off by default except for BigQuery, and App Engine and Compute Engine do not log the key name. Google may already have disabled the key under the iam.serviceAccountKeyExposureResponse policy; rotate and investigate anyway. |
| Microsoft Entra app client secret | Add a replacement credential (a certificate or federated credential beats a new secret), then az ad app credential delete --id APP_ID --key-id KEY_ID. Disable the service principal if tokens must stop now. | Service principal sign-in logs and Azure Activity logs; rotate Key Vault secrets the identity could read, as Microsoft advises. | Issued access tokens stay valid until they expire. CAE-enabled Microsoft Graph tokens last up to 24 hours and end only on events such as disabling the service principal. |
| Azure Storage account key or SAS | az storage account keys renew --resource-group RG --account-name NAME --key primary (or secondary, whichever leaked). For a user delegation SAS: az storage account revoke-delegation-keys. | Storage resource logs, which exist only if a diagnostic setting was routing them, and Activity logs for key operations. | Regenerating a key invalidates every account and service SAS signed with it, so plan reissue. Prefer Entra ID authorization and disallow Shared Key access. |
| GitHub token or GitHub App private key | Revoke the personal access token or OAuth authorization. For an App, generate a new private key, deploy it, then delete the leaked one. | Organization audit log search hashed_token:"<base64 SHA-256>" (Enterprise Cloud); new deploy keys, webhooks, OAuth apps, tokens and Actions secrets. | GitHub revokes its own tokens pushed to public repositories or gists, but confirm rather than assume. Installation tokens already minted with a leaked App key stay valid for up to 1 hour. |
| Stripe-style API key | Rotate the key in the provider dashboard and set the old key's expiration to Now. | The provider's request log for that key (Stripe: View request logs, in Workbench): refunds, payouts, payout destinations, webhook endpoints, new keys. | A scheduled expiry keeps the old key working for up to 7 days. Give the replacement a restricted scope and an IP or access policy. |
| SSH private key | Remove its public key from every authorized_keys file, Git host account and deploy key; on servers you manage, list it in RevokedKeys (a KRL) so forgotten copies are refused. Create a replacement with ssh-keygen -t ed25519. | sshd logs, which record "Accepted publickey for USER from IP port N ssh2: ED25519 SHA256:<fingerprint>"; get the fingerprint with ssh-keygen -lf key.pub. Check the Git host's audit log too. | If a host private key leaked, regenerate host keys and update known_hosts. Short-lived SSH certificates avoid hunting authorized_keys files. |
| TLS private key | Issue a certificate on a new key pair (Certbot: renew with --force-renewal --new-key), deploy it everywhere the old one served, then revoke the old certificate: certbot revoke --cert-name NAME --reason keycompromise. | Certificate Transparency logs for certificates on your names that you did not request, in case the leak came with host or DNS access. | Clients check revocation unevenly, so an on-path attacker can use the old certificate until it expires. If static RSA key exchange (TLS_RSA_* suites) was enabled, recorded past sessions can be decrypted; ECDHE and TLS 1.3 sessions cannot. |
These commands deactivate the key, deny every session credential issued before the cutoff, and pull the key's recent history from each Region. The aws:TokenIssueTime key is present only on requests signed with temporary credentials, so the deny does not touch the user's long-term credentials; it can be removed once 36 hours have passed or when the user is deleted. Then repeat the lookup for the temporary key IDs returned by any GetSessionToken or AssumeRole call the key made.
# Run as an administrator, never with the leaked key.
USER=ci-deployer
KEY=AKIAIOSFODNN7EXAMPLE
# 1. Stop the key itself.
aws iam update-access-key --user-name "$USER" --access-key-id "$KEY" --status Inactive
# 2. Session credentials minted from it (GetSessionToken) stay valid for up to 36 hours.
# Deny every session issued before now; long-term credentials are not affected.
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
cat > deny-old-sessions.json <<EOF
{"Version": "2012-10-17", "Statement": {"Effect": "Deny", "Action": "*", "Resource": "*",
"Condition": {"DateLessThan": {"aws:TokenIssueTime": "$NOW"}}}}
EOF
aws iam put-user-policy --user-name "$USER" --policy-name deny-sessions-before-revocation \
--policy-document file://deny-old-sessions.json
# 3. What did it do? Event history: 90 days of management events, one Region per call.
for r in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
aws cloudtrail lookup-events --region "$r" \
--lookup-attributes AttributeKey=AccessKeyId,AttributeValue="$KEY" \
--query 'Events[].[EventTime,EventName,Username]' --output text
doneQuery by the credential's own identifier, from the start of the exposure window until after revocation, so you also see denied retries: CloudTrail by access key ID, Cloud Audit Logs by service account key name, Entra service principal sign-ins by application ID, the GitHub audit log by token hash, the provider's request log for an API key, and sshd logs by key fingerprint.
Attackers usually begin with an identity check (AWS sts:GetCallerIdentity needs no permissions and cannot be denied), then enumerate, then read data or create resources. Instances launched in Regions you never use often mean fraud that runs up charges, which is why AWS's quarantine policy blocks instance launches. The findings that matter most are footholds that survive your revocation: new users, keys, roles or trust-policy changes, OAuth applications, SSH and deploy keys, webhooks, CI secrets, scheduled jobs and functions. Remove them before you call the incident contained.
Know the gaps. CloudTrail event history keeps 90 days of management events per Region, and S3 object reads appear only if a trail recorded data events. Google Cloud Data Access audit logs are off by default except for BigQuery, and Azure Storage resource logs exist only if a diagnostic setting routed them. Where the relevant log never existed, absence of evidence proves nothing: say so in the incident record and rotate whatever the identity could read, such as secrets in a vault it could access.
# GitHub Enterprise Cloud: find audit log events made with a leaked token.
# Read the token from a file so it never lands in shell history.
HASH=$(tr -d '\n' < leaked-token.txt | openssl dgst -sha256 -binary | base64)
rm leaked-token.txt
echo "Search the organization audit log for: hashed_token:\"$HASH\""
# Same value as GitHub's documented: echo -n TOKEN | openssl dgst -sha256 -binary | base64Rewriting history is cleanup, not containment. After a force-push the value still exists in other people's clones, in forks, in CI caches and mirrors, in pull request refs and in the hosting provider's cached views. GitHub's procedure starts by revoking the secret and notes that, once it is revoked, a rewrite may not be worth its side effects. Rewrite when the repository must not keep the value at all, for example because it is public or the secret cannot be rotated quickly.
Use git-filter-repo 2.47 or later with --sensitive-data-removal, which fetches every ref before rewriting and prints the first changed commits. Merge or close open pull requests first, lift branch protection for the force-push, and have collaborators re-clone instead of pulling: a single merge from an old clone brings the commit back. GitHub refuses pushes to refs/pull/*, so removing those refs, cached views and server-side objects takes a support request that quotes the first changed commits.
In a local test with git-filter-repo 2.47.0, a fresh clone after the force-push held no trace of a planted fake key, a teammate's clone taken before the rewrite still held it, and the server repository still returned the old commit object until garbage collection. That is why rotation stays mandatory.
# Revoke the secret first. Then rewrite in a fresh clone (git-filter-repo 2.47 or later).
git clone https://github.com/ORG/REPO.git && cd REPO
# Keep the replacements file outside the repository and delete it afterwards.
cat > ../replacements.txt <<'EOF'
AKIAIOSFODNN7EXAMPLE==>REMOVED_AWS_ACCESS_KEY_ID
regex:aws_secret_access_key = ".*"==>aws_secret_access_key = "REMOVED"
EOF
git filter-repo --sensitive-data-removal --replace-text ../replacements.txt
git log -p --all | grep -c 'AKIAIOSFODNN7EXAMPLE' # expect 0
# Overwrites every branch and tag. GitHub rejects refs/pull/*: that part needs Support,
# quoting the "First Changed Commit(s)" that git-filter-repo printed.
git push --force --mirror originScanners match known token formats and high-entropy strings, so they find most provider keys and miss many hand-made passwords. GitHub secret scanning, which runs free on public repositories and needs GitHub Secret Protection on private ones, reads the full history of every branch as well as issues, pull requests, discussions and wikis. For public repositories and public npm packages it sends partner tokens to their issuers, and GitHub revokes its own tokens pushed to public repositories or gists. Push protection for users is on by default and blocks secrets in pushes to public repositories; repository push protection, which also covers private repositories, needs GitHub Secret Protection and is off by default.
Add a local layer so a secret never leaves the laptop (the fix card at /fix/hardcoded-secrets/ covers removing secrets from code): Gitleaks or TruffleHog as a pre-commit hook, and the same tool over the full history in CI. TruffleHog verifies candidates against the provider's API by default (--no-verification turns that off); verification confirms a live secret but is itself a use of the credential and appears in the provider's logs.
Prove that the control works. With Gitleaks 8.30.1 and pre-commit 4.6.2, the published gitleaks-system hook let a commit containing an AWS-format key through: pre-commit appended the staged file names, Gitleaks treated them as the repository path, logged an error and reported no leaks. Adding pass_filenames: false, as below, made the same commit fail.
# .pre-commit-config.yaml (needs the gitleaks binary on PATH; then run: pre-commit install)
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks-system
pass_filenames: false # without this the hook scanned nothing and passed
# In CI, scan the full history of every branch:
# gitleaks git --redact --log-opts="--all" .The cheapest incident is one where the leaked value is already useless. Replace long-lived keys with credentials the platform issues for minutes or hours, and keep the static secrets that remain in a manager that rotates them and logs every read.
- CI and deployments: OIDC federation from the CI system to the cloud (for example GitHub Actions to AWS, Google Cloud or Azure) instead of stored cloud keys, with trust conditions on repository, branch and environment; /domains/identity-crypto/identity/workload-identity-models/ shows the trust policy.
- Workloads: attached roles and managed identities instead of key files; in Google Cloud, block new service account keys with the
iam.managed.disableServiceAccountKeyCreationorganization policy. - API keys that must exist: restricted scopes, IP or access policies where offered, an expiry date, an owner and a rotation procedure you have actually run; for GitHub, fine-grained tokens with an organization maximum-lifetime policy.
- Databases and internal services: dynamic credentials with a lease, so revocation is an expiry rather than a hunt.
- SSH and TLS: short-lived SSH certificates or hardware-backed keys, and automated certificate renewal, which the shrinking public certificate lifetimes now require anyway.
- Hygiene: ignore rules for env and key files, no secrets in CI output or error pages, and one tested runbook per credential type.
Containment is complete when the issuer rejects the old value, every derived session has expired or been denied, footholds found in the logs are gone, and the replacement works for legitimate consumers. Record the exposure, detection, revocation and last-malicious-use times. The gap between exposure and revocation is the number to shrink next time, and it shrinks fastest with push protection in front and short-lived credentials behind.
Guided practice
An on-call engineer receives a secret scanning alert for an AWS access key that was pushed to a public repository the previous evening. Use the supplied events to plan containment, scoping and cleanup.
Supplied scenario material
IAM user ci-deployer: managed policy PowerUserAccess; only access key AKIAIOSFODNN7EXAMPLE
2026-10-09 21:14 UTC push to public repo acme/infra-tools, commit 3f9c1e2 adds deploy.env containing the key
2026-10-09 21:16 UTC us-east-1 GetCallerIdentity key AKIAIOSFODNN7EXAMPLE from 203.0.113.50
2026-10-09 21:18 UTC us-east-1 GetSessionToken DurationSeconds=129600 returns ASIAIOSFODNN7EXAMPLE
2026-10-09 21:25 UTC ap-southeast-3 RunInstances (12 GPU instances) key ASIAIOSFODNN7EXAMPLE
2026-10-09 21:31 UTC AWS attaches AWSCompromisedKeyQuarantineV3 to ci-deployer and opens a support case
2026-10-09 21:40 UTC us-east-1 CreateUser backup-svc -> AccessDenied key ASIAIOSFODNN7EXAMPLE
2026-10-09 21:44 UTC eu-central-1 secretsmanager:GetSecretValue prod/db -> success key ASIAIOSFODNN7EXAMPLE
2026-10-10 06:02 UTC on-call reads the alert
2026-10-10 06:10 UTC the developer deletes deploy.env in a new commit and force-pushes1. Judge the first response
Decide whether the 06:10 commit and force-push contained the incident.
Expected result and reasoning
No. The key had worked for about nine hours and still works; anyone who cloned, forked or scraped the repository keeps it. The first action should have been deactivating the key with an administrator identity, then dealing with history later.
2. Contain the key and its session
List the containment actions and work out until when
ASIAIOSFODNN7EXAMPLEstays valid if you only deactivate the access key.Expected result and reasoning
Deactivate
AKIAIOSFODNN7EXAMPLE; attach anaws:TokenIssueTimedeny with a cutoff of now toci-deployer; keep the quarantine policy and follow the support case. The session was issued at 21:18 for 129,600 seconds, so without the deny it works until 2026-10-11 09:18 UTC. Terminate the instances inap-southeast-3.3. Read what the events show
Explain why GetSecretValue succeeded after the quarantine policy was attached, and what follows from it.
Expected result and reasoning
The quarantine policy denies a list of high-risk actions such as
RunInstances,CreateUserandGetSessionToken, but notsecretsmanager:GetSecretValue, andPowerUserAccessallows it. Treatprod/dbas compromised: rotate the database credential and review database access logs from 21:44 onward.4. Scope the rest
Specify the searches that remain and the gaps in the evidence.
Expected result and reasoning
Run
lookup-eventsfor both key IDs in every Region from 21:14 onward, looking for key pairs, security group changes, Lambda functions, other secrets and IAM attempts. The deniedCreateUsershows intent but no IAM foothold. S3 object reads are invisible unless a trail logged data events; record that gap.5. Clean up and prevent
Decide whether to rewrite history and what replaces the IAM user key.
Expected result and reasoning
The key is revoked, so a rewrite is optional; do it with
git-filter-repo --sensitive-data-removalif policy requires, and ask GitHub Support to purge cached views. Replace the user key with OIDC federation from CI to an IAM role, and enable push protection and a pre-commit scanner.
Events, names and key IDs are invented for practice; 203.0.113.0/24 is a documentation range and the key IDs are AWS's published examples. No AWS account is used, so confirm current CLI and policy behavior in AWS documentation before relying on it.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OWASP Secrets Management Cheat Sheet: Incident Response
- GitHub Docs: Removing sensitive data from a repository
- GitHub Docs: Push protection
- AWS IAM: Revoke IAM role temporary security credentials (aws:TokenIssueTime)
- AWS CloudTrail: Viewing recent management events with the AWS CLI
- Google Cloud IAM: Disable and enable service account keys
- Microsoft Entra ID Protection: Securing workload identities (leaked credentials remediation)
- Stripe Docs: Rotate an API key
- Let's Encrypt: Revoking certificates
- OpenSSH sshd_config(5): RevokedKeys
Reference information
- SDLC phase
- DevelopmentMaintenance
- CAPEC attack patterns