All writing
Updated 7 min readNaren

Git hooks that stop secrets before they land

A pre-commit hook that blocks AWS access key IDs, a commit-msg hook that enforces Conventional Commits, and Husky so a fresh clone arms them by itself.

A git hook is a script Git runs automatically at a fixed point in the commit or push process.

If it exits with a non-zero status, Git cancels the operation. That exit status is the whole interface.

There is no config file and no return value to inspect, just the number the script exits with, and anything but zero means stop.

git commit --no-verify skips the pre-commit and commit-msg hooks this post writes, so neither is a security boundary. Each is a fast local check on the machine where fixing the mistake is still cheap.

What git init already left you

git init demo-hooks
cd demo-hooks
ls .git/hooks

Every one of those files ends in .sample, and nothing in that directory runs. Git executes a hook only when a file is named exactly for the event, with no extension: pre-commit, not pre-commit.sample.

Two of those events gate a commit and one flag walks around both. Follow the edge labelled --no-verify: it leaves git commit and arrives at a finished commit without touching either gate.

A commit runs pre-commit then commit-msg. A non-zero exit from either aborts it. The --no-verify flag skips both. The post-commit hook runs afterwards and cannot abort. non-zero zero non-zero zero --no-verify git commit pre-commit exit status Commit aborted Message file written commit-msg exit status Commit created post-commit, cannot abort

A pre-commit hook that blocks AWS access key IDs

A commit records the staged snapshot: the changes you marked with git add, held in what Git calls the index. It can differ from the files on disk, and a hook that reads the wrong one is worse than no hook at all.

git diff --cached shows staged changes rather than unstaged edits; git show ":path" prints one file's staged version, the colon meaning from the index rather than a branch.

Write this to .git/hooks/pre-commit.

The first line, the shebang, tells the system which interpreter to run: #!/bin/sh asks for a plain POSIX shell, the portable-shell standard present on every Mac and Linux box, while #!/bin/bash asks for a specific shell that is not always installed.

.git/hooks/pre-commit
#!/bin/sh
# .git/hooks/pre-commit - refuse to commit obvious AWS keys; warn on debug statements.

# The lines this commit ADDS, read from the staged snapshot only.
added=$(git diff --cached --diff-filter=ACM -U0 | grep '^+' | grep -v '^+++')

blocked=0

# AWS access key IDs are uppercase: AKIA (long-term) or ASIA (temporary), then 16 chars.
# Keep this branch case-sensitive - folding case here only creates false positives.
if printf '%s\n' "$added" | grep -Eq '(AKIA|ASIA)[0-9A-Z]{16}'; then
  echo "pre-commit: an AWS access key ID is in your staged changes." >&2
  blocked=1
fi

# -i is the portable case-insensitive switch. POSIX ERE has no (?i) - that is -P syntax.
if printf '%s\n' "$added" | grep -Eqi 'aws_secret_access_key'; then
  echo "pre-commit: 'aws_secret_access_key' is in your staged changes." >&2
  blocked=1
fi

if [ "$blocked" -ne 0 ]; then
  echo "Unstage it, move the value to an untracked .env, and rotate the key." >&2
  echo "Assume any key you committed is already burned." >&2
  exit 1
fi

# Warning only. Reads each file's STAGED content via git show ":file",
# and uses a git pathspec instead of xargs, so filenames with spaces are safe.
git diff --cached --diff-filter=ACM --name-only -- '*.js' '*.jsx' '*.ts' '*.tsx' |
while IFS= read -r f; do
  if git show ":$f" | grep -Eq 'console\.log|debugger'; then
    echo "pre-commit: debug statement in $f (warning only)"
  fi
done

exit 0
chmod +x .git/hooks/pre-commit

Native hooks need the executable bit.

Three details in that script are easy to get wrong, and each one fails quietly.

AKIA is not the secret

AKIA is not a secret. In AWS's table of identifier prefixes it is the prefix for an access key ID, the public half of the pair.

The secret access key is 40 characters with no fixed prefix and no reliable pattern, so nothing here detects it.

ASIA marks a temporary key issued by AWS Security Token Service, or STS, the service that hands out credentials that expire, so matching only AKIA misses every short-lived key.

Key IDs are always uppercase, so keep that branch case-sensitive.

Use the -i flag, not an inline (?i)

Use -i, never (?i). grep -E matches extended regular expressions, or EREs, which have no inline option syntax; (?i) is Perl, which is grep -P.

The BSD grep that ships with macOS accepts (?i) under -E anyway, so the pattern works on the machine you wrote it on while awk and sed -E on that same machine reject it as invalid.

A check that stops matching without saying so buys false confidence. Test that your check fails when it should, not only that it passes.

Read staged content, not the working tree

Read staged content, not the working tree, which is the files as they currently sit on disk.

The usual debug-statement one-liner pipes git diff --cached --name-only into xargs grep, which takes staged filenames and greps those paths on disk.

Add a console.log after staging and it reports a line you are not committing; delete a staged one from disk and it reports nothing.

xargs also splits on whitespace, so my component.js produces grep: my: No such file or directory. A pathspec, the file-pattern filter at the end of the git command, does the filtering, and git show ":$f" reads the index.

A commit-msg hook that enforces Conventional Commits

Conventional Commits 1.0.0 is a convention for the subject line. It takes the form <type>[optional scope]: <description>, so a tool can tell a feature from a fix without reading English.

The spec defines only feat and fix. The hook below accepts nine more: build, chore, ci, docs, perf, refactor, revert, style, test. Those come from @commitlint/config-conventional, a rule set for commitlint, the commit-message linter this post installs later.

A ! immediately before the colon marks a breaking change: feat!: drop v1 api.

Release tools read that format: semantic-release and conventional-changelog derive changelog entries from those subject lines. If you would rather keep a changelog hand-written for people to read, that is a reason to skip this hook.

.git/hooks/commit-msg
#!/bin/sh
# .git/hooks/commit-msg - check the subject line against Conventional Commits.
# Git passes the path to the message file as the first argument, "$1".

msg_file="$1"

# The subject is the first line that is neither blank nor a Git comment.
subject=$(grep -v '^#' "$msg_file" | sed '/^[[:space:]]*$/d' | head -n 1)

# Let Git's own generated messages through, or nothing would ever merge.
case "$subject" in
  "Merge "*|"Revert "*|"fixup! "*|"squash! "*|"") exit 0 ;;
esac

# type, optional (scope), optional ! for a breaking change, then ": description".
pattern='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-zA-Z0-9 ._/-]+\))?!?: .+'

if ! printf '%s\n' "$subject" | grep -Eq "$pattern"; then
  echo "commit-msg: '$subject' is not a Conventional Commit." >&2
  echo "Use  <type>(<optional scope>): <description>" >&2
  echo "Types: build chore ci docs feat fix perf refactor revert style test" >&2
  echo "Put ! before the colon for a breaking change, e.g. feat!: drop v1 api" >&2
  echo "Example: feat(auth): add token refresh endpoint" >&2
  exit 1
fi
chmod +x .git/hooks/commit-msg

Git's githooks documentation says commit-msg is invoked by git commit and git merge, and Git's generated merge message is Merge branch 'side', which no Conventional Commits pattern accepts.

Install a plain Conventional Commits pattern without that case whitelist and every merge that writes a merge commit fails, along with git revert and git commit --fixup.

A bare head -n 1 is the same flavour of bug: a blank or # first line reads as an empty subject, and the hook rejects a message Git would have taken.

The !? matters for the same reason. Shorter versions of this pattern leave the ! out, so they reject feat!: drop v1 api, the exact syntax the spec prescribes for breaking changes.

The character class for the scope is wide enough to accept feat(api/v2): and feat(ui.core):, which the spec permits, a scope being just a noun naming part of the codebase.

Sharing the hooks, and the line everyone forgets

Pro Git states it flatly: client-side hooks are not copied when you clone. The way around that is to commit the scripts and point Git at the committed directory.

mkdir .githooks
mv .git/hooks/pre-commit .githooks/pre-commit
mv .git/hooks/commit-msg .githooks/commit-msg
git add .githooks
git commit -m "chore(git): add version-controlled git hooks"

# Requires Git 2.9 or newer. This writes to .git/config, which is NOT committed,
# so EVERY collaborator must run this line once in their own clone.
git config core.hooksPath .githooks

The last line is the one that does not travel.

The Git 2.9 release notes announce core.hooksPath as a way to customise where the hook directory lives, and that setting sits in each clone's own .git/config, which is not version-controlled.

Clone a repository whose .githooks directory is fully committed and core.hooksPath comes up unset. A staged aws_secret_access_key=leak then commits cleanly while the hook sits unread in the working tree.

Committing .githooks/ shares the scripts; every collaborator still runs that git config line once, or you have shared a folder rather than a check.

Move both hooks, not one. core.hooksPath replaces the hooks directory rather than adding to it, so anything left in .git/hooks stops running, including the commit-msg hook from ten minutes ago.

Git preserves the executable bit through a clone, so the scripts arrive runnable.

Or hand the scanning to something maintained

A regex matches the credential formats you thought of. gitleaks is a secret scanner that ships and maintains those patterns for you, and it is what to reach for when your own regex stops being enough.

brew install gitleaks          # macOS; see the repo for Linux and Docker
gitleaks git --staged --no-banner --redact

That is gitleaks v8.30.1, where --staged is the documented pre-commit mode. Do not write gitleaks protect --staged: detect and protect were deprecated in v8.19.0 and hidden from --help.

The current commands are git, dir and stdin.

Husky, so a fresh clone arms itself

Husky 9.1.7 is an npm package that takes over that git config line, so no collaborator has to run it by hand. Check the Node version before you install.

The engines fields on npm are explicit: lint-staged 17.5.0 needs Node 22.22.1 or newer and @commitlint/cli 21.2.2 needs 22.12.0, so the floor is Node 22.22.1, not the >=18 husky alone asks for.

On Node 18 or 20 the install fails with EBADENGINE. You also need an existing package.json.

# Needs Node 22.22.1+ (lint-staged 17 requires it) and an existing package.json.
node --version
npm install --save-dev husky lint-staged @commitlint/cli @commitlint/config-conventional
npx husky init

husky init does three things: it sets "prepare": "husky" in package.json, creates .husky/, and writes .husky/pre-commit containing npm test. Replace those contents; do not append, or every commit runs your whole test suite.

package.json
{
  "name": "payment-service",
  "version": "1.0.0",
  "scripts": {
    "prepare": "husky"
  },
  "lint-staged": {
    "*.{js,ts,jsx,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,yaml,yml}": [
      "prettier --write"
    ]
  }
}

lint-staged runs your linters and formatters against only the files you staged.

prepare is an npm lifecycle script that runs after npm install, and it is what the shared-hooks claim rests on: a colleague clones, installs, and Husky re-arms itself.

The hole that opens in CI

There is one hole, and it opens in continuous integration, on the server that builds and tests every push.

Husky's documentation warns that "prepare": "husky" may fail when only dependencies are installed, so a CI job running npm ci --omit=dev fails there for no visible reason. Set HUSKY=0 in CI.

.husky/pre-commit
lint-staged

No shebang, no chmod

No shebang and no chmod. Husky's own helper script is five readable lines: it exports PATH="node_modules/.bin:$PATH" and then runs your hook with sh -e "$s".

Invoking sh explicitly means nothing ever reads the shebang or checks the executable bit, and because node_modules/.bin is already on the path, npx inside the hook is belt and braces.

Husky v9 deprecated the shebang and the old . "$(dirname -- "$0")/_/husky.sh" line and drops support in v10.

commitlint.config.js
export default {
  extends: ['@commitlint/config-conventional']
};

The config file is an ES module

That is an ES module, using export default rather than module.exports, which is the form commitlint's current guide shows.

module.exports throws ReferenceError: module is not defined in ES module scope in any package with "type": "module", a common setup. It is a miserable first error to debug.

If your package.json has no "type": "module" and export default fails, rename the file commitlint.config.mjs.

echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg

--no tells npx to fail rather than silently downloading commitlint when it is not installed. No chmod on this one either.

Where enforcement lives

git commit --no-verify -m "chore: emergency fix"   # -n is the short form
git push --no-verify
HUSKY=0 git commit -m "chore: skip husky hooks"    # Husky-specific

--no-verify bypasses pre-commit and commit-msg; on push it bypasses pre-push. The flag is there by design, and that is what makes a local hook a convenience rather than a control.

The usual advice at this point is to put unbypassable rules in a pre-receive hook on the server. Check first whether you can.

Pre-receive hooks are a GitHub Enterprise Server feature, scripts that run on the appliance, with no equivalent on github.com; GitLab's server hooks page is marked GitLab Self-Managed and installing one needs the gitaly command plus filesystem access.

On hosted GitHub or GitLab you cannot install one at all.

Use branch protection rules and required status checks instead, plus your provider's secret scanning with push protection.

So the rules run twice: in the hook for the fast local answer, and again on the server, where no local flag reaches them.

For the same reason, image and filesystem scanning belongs in the pipeline and not only on somebody's laptop.

A pre-commit hook runs on every commit, so anything slow enough to notice gets bypassed, and a hook everyone bypasses enforces nothing.

There is no published threshold worth quoting; time your own with time git commit on a real change. Slow work belongs in pre-push or CI.

And if a key does get committed, the hook has already failed at the job it was there for. Rewriting history does not un-leak it.

Rotate the key. Issue a new one, invalidate the old, and assume the committed one is burned.