Crisis response for server operators

A key or token was published

When a key reaches a public place: the order of revocation, finding since when and by whom it could be fetched, handling the history, and stopping the next publish.

  • Level: Basics
  • About 40 min
  • 2026-11-12

#The situation

An AWS key is sitting in your live site's JavaScript

A colleague writes: "The site's app.js has an AWS access key in plain text." You find the key hard-coded in the repository's src/upload.js. It was built and copied to the public folder, and the source map (app.js.map) went public with it.

When a key or token reaches a public place, there are only a few decisions to make. But get the order wrong and the key stays usable the whole time. Revoke before you delete. Revoke before you rewrite history. This playbook turns that order, and the investigation, into steps.

Key formats and how the scans work are covered in Credential theft and leaked secrets. Here you run the decisions and steps after a find, on a disposable VM.

StepWhat you doWhat you use
1. SeeFind the key in the repository and the public folderAPI key and private key detection, Secrets in published front-end files
2. InvestigateSince when, where, and who could fetch itgit log -S, the access log, Access log diagnosis (AccessScope)
3. RespondRevoke at the issuer, handle history, replaceThe issuer's console, git filter-repo
4. PreventStop a publish that still carries a keyThe exit code of roamswitch scan-frontend
5. Clean upRemove the practice setup
Practice on a disposable machine, with an invalid key

The key in this playbook is the invalid sample from the AWS documentation (AKIAIOSFODNN7EXAMPLE and its paired secret access key). No real key is used. Every scan here only reads local files and sends nothing to any outside service. All requests to the site are made from inside the VM.

#Set up the practice

Install nginx and git-filter-repo on the VM, and create a dummy repository. The second commit adds the key, and the built app.js and its source map go into the public folder. The commit dates are set to fit the story.

Create a dummy repository and a published file with a key

This installs packages and writes files (it changes the system). Cleanup is after the last lab.

sudo apt-get install -y nginx git-filter-repo
mkdir -p ~/shop/src && cd ~/shop
git init -q -b main
git config user.name "Dev Sample"; git config user.email dev@example.com
echo '# shop (dummy for practice)' > README.md
printf 'import { upload } from "./upload.js";\nupload();\n' > src/main.js
git add -A
GIT_AUTHOR_DATE="2026-10-20T10:00:00+09:00" GIT_COMMITTER_DATE="2026-10-20T10:00:00+09:00" \
  git commit -qm "Initial commit"
cat > src/upload.js <<'J'
// Practice only: the invalid sample key from the AWS documentation
const cfg = {
  accessKeyId: "AKIAIOSFODNN7EXAMPLE",
  secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
};
export function upload() { return cfg.accessKeyId.length; }
J
git add -A
GIT_AUTHOR_DATE="2026-10-22T15:30:00+09:00" GIT_COMMITTER_DATE="2026-10-22T15:30:00+09:00" \
  git commit -qm "Add image upload"
# Build and publish (the way the key reached the public folder)
mkdir -p dist
{ cat src/upload.js src/main.js; echo '//# sourceMappingURL=app.js.map'; } > dist/app.js
echo '{"version":3,"sources":["src/upload.js","src/main.js"],"mappings":""}' > dist/app.js.map
sudo cp dist/app.js dist/app.js.map /var/www/html/
git log --oneline
Output
96653d2 Add image upload
c5e1f37 Initial commit

#Read the findings

A key ends up in two kinds of place: the source in the repository, and the build output in the public folder. There is one command for each.

Scan the repository and the public folder

Both only read; nothing is changed. Use audit-secrets for the repository and scan-frontend for the public folder.

cd ~
roamswitch audit-secrets ./shop
sudo -E roamswitch scan-frontend /var/www/html
echo "exit code: $?"
Output
=====================================================
  🔑 Secret & API Key Leak Audit (directory)
=====================================================

  Scanned 3 files.

  ./shop/src/upload.js
    ⚠️  AWS Access Key ID (AWS) — line 3
        Masked: AKIAIO...MPLE
        ↳ Recommendation: Deactivate the access key in the AWS IAM console.

  Found 1 exposure across 1 file. Address these before committing or sharing.
=====================================================
  Check of the files a web front end publishes
=====================================================

Looked at: . (3 files)
- Secret (API key, private key, ...): app.js:3 AWS AKIAIO...MPLE
- Source map (hands out the original source code): app.js.map
2 findings (secrets 1, tokens 0, source maps 1)
A key or token found here has already left your hands if the files are published: revoke it at its issuer and create a new one. Keep secrets out of the build and on the server. Do not publish source maps, or restrict who can fetch them.
exit code: 1
  • audit-secrets looked at 3 files. It does not enter .git or dist. The published build is what scan-frontend is for
  • The secret access key on line 4 of upload.js is not reported, because it has no fixed shape. Once its paired ID (line 3) is found, treat the whole key pair as leaked
  • scan-frontend also lists the source map. A source map lets anyone read the original source
  • scan-frontend exits with code 1 when it finds something, and so does audit-secrets. The last section uses that

#Since when, where, and who could fetch it

You do not need a long investigation before revoking. But to know what to look for in the issuer's logs, pin down since when the key was public. Here you first play outside visitors from inside the VM, then investigate.

Check the commit date, the publish time and who fetched the file

The first block makes requests from an ordinary visitor and from something hunting for .env and the like. The VM's own address stands in for an outside client.

IP=$(hostname -I | awk '{print $1}')
curl -s -o /dev/null -A "Mozilla/5.0" http://127.0.0.1/app.js
for p in /.env /.git/config /app.js /app.js.map /.aws/credentials; do
  curl -s -o /dev/null -A "python-requests/2.31" "http://$IP$p"
done
cd ~/shop
git log -S AKIAIOSFODNN7EXAMPLE --date=iso --format='%h %ad %an <%ae> %s'
stat -c '%n %U:%G %a %y' /var/www/html/app.js src/upload.js
sudo grep -E '"GET /app\.js' /var/log/nginx/access.log
sudo -E accessscope scan /var/log/nginx/access.log
excerpt
96653d2 2026-10-22 15:30:00 +0900 Dev Sample <dev@example.com> Add image upload
/var/www/html/app.js root:root 644 2026-11-12 01:37:56.696528531 +0000
src/upload.js admin:admin 644 2026-11-12 01:37:56.595875759 +0000
127.0.0.1 - - [12/Nov/2026:01:37:56 +0000] "GET /app.js HTTP/1.1" 200 327 "-" "Mozilla/5.0"
192.168.64.12 - - [12/Nov/2026:01:37:56 +0000] "GET /app.js HTTP/1.1" 200 327 "-" "python-requests/2.31"
192.168.64.12 - - [12/Nov/2026:01:37:56 +0000] "GET /app.js.map HTTP/1.1" 200 70 "-" "python-requests/2.31"

| # | Severity | Confidence | Finding | Count | Main sources |
|---|---|---|---|---|---|
| 1 | Low | Likely | Probing for secrets, config files and admin pages | 3 (1 IP) | 192.168.64.12(3) |
  • Since when: git log -S shows the commit that added the string. The key went in on 22 October at 15:30 JST (06:30 UTC). Search the issuer's logs from that moment on
  • Where: the stat time is when the file was placed in the public folder, in UTC (+0000). In real operations, the public period also includes any earlier deploys
  • Who could fetch it: two clients received app.js with a 200. app.js is part of an ordinary page, so every visitor received the key
  • The 3 requests AccessScope reports are the hunts for .env, .git/config and .aws/credentials. --report FILE prints the evidence log lines. Fetching an ordinary file does not count as probing
An access log shows who fetched, not who used

The access log tells you who received the file. It does not tell you whether the key was used. Only the issuer's activity log (CloudTrail for AWS, for example) shows that.

#The order of revocation

Once a key is in a public place, revoke it at the issuer before you delete any file. This one step happens outside RoamSwitch. The scan's own recommendation says the same: deactivate the access key in the AWS IAM console.

  1. Revoke at the issuer. For AWS, deactivate the access key in IAM
  2. Look for signs of use. Read the issuer's activity log from the moment the key went in. The log stays after revocation
  3. Create a new key and change where it lives. Do not put it in any file that reaches the browser
  4. Remove it from the source and the history. The next section covers this
  5. Add a check that stops a publish. The last section covers this
Revoke now, or replace first and then revoke

Revoking a key stops whatever ran on it. A key that reached a public place (a public site, a public repository) is revoked at once, and you accept the outage, because you cannot know who holds it. If the key was only in an internal repository and the readers are limited, you may create the new key first, swap it in, and revoke the old one right after. Either way, the old key is always revoked.

What not to do
  • Delete the key from the file and stop there, without revoking it
  • Rewrite history before revoking (the key stays usable meanwhile)
  • Try the leaked key yourself to "see if it still works"
  • Paste the full key into a chat or issue to share the situation (use the masked form)

#Handling the history

Deleting the key from the source and committing does not remove it: the key stays in the earlier commits. A folder scan with audit-secrets does not enter .git, so the history needs its own check.

Confirm the key is still in the history after deleting it

Pretend the code that uses the key moved to the server: rewrite upload.js and commit. Then scan the current files and every past version.

cd ~/shop
cat > src/upload.js <<'J'
// The key now stays on the server. The page asks the server for a one-time upload URL.
export async function upload() { return (await fetch("/api/upload-url")).ok; }
J
GIT_AUTHOR_DATE="2026-11-04T10:50:00+09:00" GIT_COMMITTER_DATE="2026-11-04T10:50:00+09:00" \
  git commit -qam "Move upload signing to the server"
roamswitch audit-secrets ./src                   # the current files
roamswitch audit-secrets "$(git log -p --all)"   # every past version
Output
=====================================================
  🔑 Secret & API Key Leak Audit (directory)
=====================================================

  Scanned 2 files.

  No secret or API key exposure detected.
=====================================================
  🔑 Secret & API Key Leak Audit
=====================================================

  Detected 2 exposed secrets or API keys. Remove them before committing or sharing.

  ⚠️  AWS Access Key ID (AWS): line 14
      Masked: AKIAIO...MPLE (entropy: 3.68)
      ↳ Recommendation: Deactivate the access key in the AWS IAM console.

  ⚠️  AWS Access Key ID (AWS): line 35
      Masked: AKIAIO...MPLE (entropy: 3.68)
      ↳ Recommendation: Deactivate the access key in the AWS IAM console.

Nothing is found in the current files. The history diff (git log -p) has two hits: the removed line (-) and the added line (+). The line numbers are lines of the git log -p output. Anyone with the repository can read the old version with git show.

Should you rewrite the history?

Rewriting history does not remove the key from clones people already have, or from copies kept wherever it was published. The fix for a leaked key is revocation. A rewrite changes commit IDs and everyone has to clone again. Rewrite anyway when you want a revoked key to stop showing up in every scan, or you do not want it in the repository you hand out from now on. Do it after revoking.

Replace the key throughout the history

git filter-repo replaces the key with REMOVED in every commit. This rewrites history (it changes the system). Make a full copy first so you can go back.

cd ~
cp -a shop shop.bak                              # a backup (it still holds the key)
cd shop
printf '%s\n' 'AKIAIOSFODNN7EXAMPLE==>REMOVED' \
  'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY==>REMOVED' > /tmp/replace.txt
git log --oneline
git filter-repo --force --replace-text /tmp/replace.txt
git log --oneline
roamswitch audit-secrets "$(git log -p --all)"
Output
9aa9bba Move upload signing to the server
96653d2 Add image upload
c5e1f37 Initial commit
Parsed 3 commitsHEAD is now at 7e184d5 Move upload signing to the server

New history written in 0.02 seconds; now repacking/cleaning...
Repacking your repo and cleaning out old unneeded objects
Completely finished after 0.08 seconds.
7e184d5 Move upload signing to the server
ea1a703 Add image upload
c5e1f37 Initial commit
=====================================================
  🔑 Secret & API Key Leak Audit
=====================================================

  No exposed secrets or API keys were detected.
  • The first commit (c5e1f37), which never held the key, keeps its ID. Every commit from the one that added the key onward gets a new ID
  • Scanning the rewritten history finds nothing
  • To go back, put shop.bak back in place
This changes the system

git filter-repo rewrites the local repository's history. Applying it to a shared repository needs a force push, and everyone else must clone again. The backup shop.bak still holds the key. Delete it once you have checked the result.

#Replace, and check before publishing

The new key never goes into a file that reaches the browser. In this story, the server now issues the upload URL (the commit in the previous section). Next, make sure a publish that still carries a key stops. scan-frontend exits with 1 when it finds something; put it in the middle of the publish steps.

Make a version with a key unpublishable

A short script that builds, checks, and copies to the public folder only when the check passes. Try it on the version that held the key (the backup moved back one commit) and on the current one. Finally, delete the source map still in the public folder (this changes the system).

cd ~
cat > deploy.sh <<'D'
#!/bin/sh
# Build into dist, check it, then copy it to the public folder
set -e
cd "$1"
rm -rf dist && mkdir dist
cat src/upload.js src/main.js > dist/app.js
roamswitch scan-frontend dist || { echo "Publishing stopped"; exit 1; }
sudo cp dist/app.js /var/www/html/
D
chmod +x deploy.sh
cd ~
git -C shop.bak checkout -q HEAD~1    # the version that held the key
./deploy.sh ~/shop.bak; echo "exit code: $?"
./deploy.sh ~/shop; echo "exit code: $?"
sudo rm /var/www/html/app.js.map
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/app.js.map
sudo -E roamswitch scan-frontend /var/www/html
Output
=====================================================
  Check of the files a web front end publishes
=====================================================

Looked at: . (1 file)
- Secret (API key, private key, ...): app.js:3 AWS AKIAIO...MPLE
1 finding (secrets 1, tokens 0, source maps 0)
A key or token found here has already left your hands if the files are published: revoke it at its issuer and create a new one. Keep secrets out of the build and on the server. Do not publish source maps, or restrict who can fetch them.
Publishing stopped
exit code: 1
=====================================================
  Check of the files a web front end publishes
=====================================================

Looked at: . (1 file)
Nothing was found in the files that would be published
exit code: 0
404
=====================================================
  Check of the files a web front end publishes
=====================================================

Looked at: . (2 files)
Nothing was found in the files that would be published
  • The version with the key stops at the check and is not copied to the public folder
  • The current version passes with exit code 0 and replaces app.js
  • With the source map deleted, app.js.map returns 404. A fresh scan of the public folder finds nothing

Clean up the practice. The backup still holds the key, so it goes too. nginx and git-filter-repo were installed only for this practice; remove them only on a disposable VM.

cd ~
rm -rf shop shop.bak deploy.sh /tmp/replace.txt
sudo rm -f /var/www/html/app.js
sudo apt-get purge -y -qq nginx nginx-common git-filter-repo > /dev/null
sudo rm -rf /var/www/html /var/log/nginx
systemctl is-active nginx
Output
inactive

#Blind spots and review

What this playbook checked, and what it did not
  • Only the published invalid sample key was used. Revoking at the issuer, reading its activity log, and issuing a new key happen outside RoamSwitch and were not run here
  • The scans find keys in formats they know. Here too, the secret access key was not reported. "Not found" does not prove "not there"
  • In a folder scan, audit-secrets does not enter .git, node_modules, dist, build and the like. Check the history by passing it git log -p
  • scan-frontend reads files ending in js, mjs, cjs, css, map, html, json and txt, plus .env, .env.* and *.env (checked in the code). Also run audit-secrets to catch a key in a file with any other name
  • The access log shows only who fetched a file. Copies kept by a CDN, a search engine cache or someone's clone are invisible from this server
Check yourself

You removed the key from the live app.js and redeployed, and scan-frontend now finds nothing. What do you do with the key?

Show the explanation

Every visitor received the app.js that held the key. Fixing the file does not erase the copies they received. The core of the response is revoking at the issuer.

Check yourself

After deleting the key from the source and committing, roamswitch audit-secrets ./shop finds nothing. Which reading is right?

Show the explanation

A folder scan does not enter .git. To check past versions, pass the output of git log -p to audit-secrets.

Next, Credential theft and leaked secrets covers the key formats the scans find and how to handle source maps. For a web shell placed in the public folder, see A web shell was found.

Features covered

Related techniques (ATT&CK): T1204.004

What was verified

Verified on 2026-11-12. Target: Linux 1.17.39. On a disposable Ubuntu 24.04 VM with the published 1.17.39, ran every lab in Japanese and English with a dummy repository, nginx and the invalid AWS sample key.

Command output varies with the product version and the environment. If yours differs, check the version first.