>_C2CZ
Serverless · Self-Hosting · Security · Independent Ops

error: gpg.ssh.allowedSignersFile needs to be configured

Kai Tanaka covers SSH, TLS and applied cryptography at C2CZ. Author page · All authors.

error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification means git cannot check your signatures, not that they are missing. The same run that prints that line on a signed commit will also print No signature a line later, which is the single most misleading message in git's signing stack: the commit is signed, the signing key was fine, and the only missing piece is the file that lists which keys you trust. Everything quoted on this page was executed on this host — OpenSSH 10.0p2, git 2.47.3, arm64 Linux 6.17 — against a throwaway repository and three throwaway keys, and every block below is pasted from that run, not paraphrased.

TL;DR — four states, four different messages

  • No gpg.ssh.allowedSignersFile configured. git log --show-signature prints error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification and then No signature. git log --format=%G? reports U. The commit object still contains a complete BEGIN SSH SIGNATURE block — proof below.
  • File configured, key or principal not listed. git prints Good "git" signature with ED25519 key SHA256:… followed by No principal matched. and exits 1 from git verify-commit. The signature is cryptographically valid and still untrusted — that is the distinction %G? encodes as U.
  • File configured, key listed, all good. Good "git" signature for ops@build-01 with ED25519 key SHA256:…, %G? = G, git verify-commit exits 0.
  • Verifying a commit by hand needs two removals, not one. The armour inside a commit carries git's gpgsig header prefix on its first line and a single leading space on every continuation line. Keep the prefix and the parser stops with missing header; drop the prefix but keep the fold and it stops with missing footer. Remove both and the same commit verifies clean.
  • The operational trap. The options field in an allowed_signers entry is comma-separated. Write valid-after="…" valid-before="…" with a space and the field ends after the first option: the line is rejected as invalid key on OpenSSH 10.0p2, in both orders, and every commit in your pipeline stops verifying. valid-after="…",valid-before="…" is one field and enforces both bounds together.
  • The fix is three config lines plus a file, and a CI verifier that checks signatures with ssh-keygen -Y verify directly — full script at the end, exit codes included, because ssh-keygen -Y verify writes its failure line to stdout, not stderr.

Three moving parts, and which one is broken

SSH commit signing has three independent pieces and they fail with three different messages. Keeping them apart is the whole diagnosis.

The signature is an SSHSIG blob created by ssh-keygen -Y sign. git signs a commit in the git namespace and stores the result as a gpgsig header inside the commit object. The allowed_signers file maps a principal (an identity string, in practice an email) to one or more public keys. The principal is whatever identity you ask to match against — git reads it back out of the file with ssh-keygen -Y find-principals and passes that string with -I, and it never compares it to the commit's committer address.

LayerWhat it answersWhere it lives
SignatureWas this exact payload signed by the holder of this key?Inside the commit object, in gpgsig
Key trustIs this key one we accept for this principal?gpg.ssh.allowedSignersFile
NamespaceWas the signature made for this purpose?-n git on verify, stored in the blob

When people say "my signature is invalid", 95% of the time the signature layer is untouched and the trust layer is empty. That is why the message mentions a config key rather than a cryptographic failure: git never got as far as the maths.

The strings, measured

Here is every failure and its exact output on OpenSSH 10.0p2 + git 2.47.3, produced by signing one payload with one ed25519 key and then breaking one thing at a time. Reproduce it in about a minute:

#!/usr/bin/env bash
# Repro harness: one key, one payload, six ways to fail.
set -u
D=${TMPDIR:-/tmp}/sshsig-repro
rm -rf "$D"; mkdir -p "$D"; cd "$D" || exit 1

ssh-keygen -q -t ed25519 -N "" -C "ops@build-01" -f "$D/id_ed25519"
printf 'ops@build-01 %s\n' "$(cat "$D/id_ed25519.pub")" > "$D/allowed_signers"

printf 'release 2026.10.13\n' > "$D/payload.txt"
ssh-keygen -Y sign  -f "$D/id_ed25519" -n file "$D/payload.txt"

# 1. happy path
ssh-keygen -Y verify -f "$D/allowed_signers" -I ops@build-01 -n file \
  -s "$D/payload.txt.sig" < "$D/payload.txt"

# 2. principal that is not in the file (signature untouched)
ssh-keygen -Y verify -f "$D/allowed_signers" -I ops@build-99 -n file \
  -s "$D/payload.txt.sig" < "$D/payload.txt"

# 3. payload changed after signing
printf 'release 2026.10.13-hotfix\n' > "$D/tampered.txt"
ssh-keygen -Y verify -f "$D/allowed_signers" -I ops@build-01 -n file \
  -s "$D/payload.txt.sig" < "$D/tampered.txt"

# 4. file with a principal and no key
printf 'ops@build-01\n' > "$D/broken_signers"
ssh-keygen -Y verify -f "$D/broken_signers" -I ops@build-01 -n file \
  -s "$D/payload.txt.sig" < "$D/payload.txt"

# 5. file that does not exist
ssh-keygen -Y verify -f "$D/missing_signers" -I ops@build-01 -n file \
  -s "$D/payload.txt.sig" < "$D/payload.txt"

# 6. sign with a key that is not on disk
ssh-keygen -Y sign -f "$D/id_ghost" -n file "$D/payload.txt"

The output, in order, with exit codes as reported by the shell:

Good "file" signature for ops@build-01 with ED25519 key SHA256:AAx1mhkUAuO11h4BLXksEpZps39Sh9Mvx9aUFPmOpqc
# rc=0

Could not verify signature.
# rc=255

Signature verification failed: incorrect signature
Could not verify signature.
# rc=255

/tmp/sshsig-repro/broken_signers:1: missing key
Could not verify signature.
# rc=255

Unable to open allowed keys file "/tmp/sshsig-repro/missing_signers": No such file or directory
Could not verify signature.
# rc=255

Couldn't load public key /tmp/sshsig-repro/id_ghost: No such file or directory
# rc=255

Two things stand out. First, an unlisted principal produces no diagnostic beyond the generic line — case 2 and a genuinely wrong signature are indistinguishable from the output alone, which is exactly why people chase the wrong problem for an hour. Second, the specific line goes to stdout, and stderr stays empty. Discard stderr and the reason is still there; discard stdout and the command fails in total silence.

# failing case, stderr discarded: the reason is on stdout, so it survives
ssh-keygen -Y verify -f "$D/allowed_signers" -I ops@build-99 -n file \
  -s "$D/payload.txt.sig" < "$D/payload.txt" 2>/dev/null
# Could not verify signature.
# rc=255

# same failure, stdout discarded, stderr kept: nothing is printed at all
ssh-keygen -Y verify -f "$D/allowed_signers" -I ops@build-99 -n file \
  -s "$D/payload.txt.sig" < "$D/payload.txt" 2>&1 >/dev/null
# rc=255            (no output: stderr is empty for this failure)

One more parser detail worth knowing before it wastes an afternoon in a CI log parser: OpenSSH writes its diagnostics with a carriage return, so lines arrive as Couldn't verify signature: namespace does not match\r\n when stderr is not a terminal. If a wrapper compares strings byte-for-byte, strip the \r first.

Why git says "No signature" on a commit that is signed

Start a repository, enable SSH signing, make a commit, and then do not configure the allowed signers file. git will happily create the commit — signing and verification are configured separately, and no default file is assumed.

cd "$D" && git init -q -b main repo && cd repo
git config user.name  "Ops Build"
git config user.email "ops@build-01"
git config gpg.format  ssh
git config user.signingkey "$D/id_ed25519.pub"
git config commit.gpgsign  true

printf 'FROM alpine:3.20\n' > Dockerfile
git add Dockerfile
git commit -q -m "build: pin base image"          # rc=0, commit written

git log --show-signature -1

That last command is where the confusion starts. Real output:

error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification
commit c7cdae015e7c8e94dbcd3fe20083231ba89c47cb
No signature
Author: Ops Build <ops@build-01>
Date:   Tue Oct 13 21:32:52 2026 +0200

    build: pin base image

No signature is a lie of omission. The commit object does contain a signature — one gpgsig block, which the same repository prints as soon as the trust file exists:

git cat-file commit c7cdae0 | sed -n '/BEGIN SSH SIGNATURE/,+1p'
# gpgsig -----BEGIN SSH SIGNATURE-----
#  U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg6McUKRRG6B5r+Xh/EAO8DmVN5B

git cat-file commit c7cdae0 | grep -c 'BEGIN SSH SIGNATURE'
# 1

git config gpg.ssh.allowedSignersFile "$D/allowed_signers"
git log --show-signature -1
# commit c7cdae015e7c8e94dbcd3fe20083231ba89c47cb
# Good "git" signature for ops@build-01 with ED25519 key SHA256:AAx1mhkUAuO11h4BLXksEpZps39Sh9Mvx9aUFPmOpqc
# Author: Ops Build <ops@build-01>

Note the leading space on the U1NIU0lH… line. That single character is the difference between a script that works and a script that reports Couldn't parse signature: missing header, and it is the subject of the next section.

The percentages git log --format=%G? uses for SSH signatures are narrow but useful: G when the signature verifies and a principal in the file matches; U when the signature is good but nothing in the file vouches for it; N when there is no signature at all. Measured across a history containing one tampered-trust commit and one unsigned commit:

git log --format='%h %G? %GS %s'
# ce79488ab31c G ops@build-01 build: fourth layer
# f188962c3364 U              build: third layer      <- key not listed
# c7cdae015e7c G ops@build-01 build: pin base image
# 7a71f2776b06 N              chore: unsigned commit

%GS (signer) is empty in the U row on purpose: git found no principal, so it has no name to print. The signature itself is intact — git verify-commit shows that in as many words:

git verify-commit f188962
# Good "git" signature with ED25519 key SHA256:2qqnSCQBRT6GHLSg2Hpn8qX0RAtCZm9eCF84vzy3Hwg
# No principal matched.
# rc=1

That richer verdict is git's own doing, not ssh-keygen's: git runs -Y find-principals and then -Y check-novalidate, so it can report "signature good, no principal matched". A direct ssh-keygen -Y verify on the same commit against the same file collapses both stages into one generic line, as the next section shows.

With the trust file unset, the same command degrades further, and this is the exact sequence to expect in a fresh CI container:

git -c gpg.ssh.allowedSignersFile= verify-commit c7cdae0
# Good "git" signature with ED25519 key SHA256:AAx1mhkUAuO11h4BLXksEpZps39Sh9Mvx9aUFPmOpqc
# Unable to open allowed keys file "": No such file or directory
# sig_find_principals: sshsig_find_principal: No such file or directory
# No principal matched.
# rc=1

Verifying a commit by hand: the one-space trap

git folds the armour into the commit header, which adds two things that must both come off: the gpgsig keyword on the first line and one leading space on every continuation line. Remove neither, or only one of the two, and ssh-keygen stops inside the armour parser — never reaching the crypto:

# WRONG #1: the block still carries git's "gpgsig " header prefix
git cat-file commit c7cdae0 \
  | sed -n '/BEGIN SSH SIGNATURE/,/END SSH SIGNATURE/p' > "$D/sig-prefixed.txt"
ssh-keygen -Y verify -f "$D/allowed_signers" -I ops@build-01 -n git \
  -s "$D/sig-prefixed.txt" < "$D/payload-commit.txt"
# Couldn't parse signature: missing header
# sig_verify: sshsig_armor: invalid format
# Could not verify signature.
# rc=255

# WRONG #2: prefix gone, the one-space fold on every continuation line kept
sed 's/^gpgsig //' "$D/sig-prefixed.txt" > "$D/sig-folded.txt"
ssh-keygen -Y verify -f "$D/allowed_signers" -I ops@build-01 -n git \
  -s "$D/sig-folded.txt" < "$D/payload-commit.txt"
# Couldn't parse signature: missing footer
# sig_verify: sshsig_armor: invalid format
# Could not verify signature.
# rc=255

The verification also needs the unsigned payload: the commit object with the whole gpgsig block removed and everything else — tree, author, committer, blank line, message — preserved byte for byte. One awk pass produces both halves, which is what a manual audit needs:

#!/usr/bin/env bash
# c2cz-verify-one.sh — verify a single commit's SSH signature with no git config at all.
set -euo pipefail

ALLOWED=${ALLOWED_SIGNERS:?set ALLOWED_SIGNERS to your allowed_signers file}
SHA=${1:?usage: c2cz-verify-one.sh <commit-ish> [principal]}

command -v ssh-keygen >/dev/null || { echo "ssh-keygen missing" >&2; exit 2; }
[ -f "$ALLOWED" ] || { echo "no such allowed signers file: $ALLOWED" >&2; exit 2; }

TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT

git cat-file commit "$SHA" > "$TMP/raw"
grep -q '^gpgsig ' "$TMP/raw" || { echo "$SHA carries no signature" >&2; exit 1; }

# split: armour with git's "gpgsig " prefix and one-space fold removed,
# and the payload with the whole block removed
awk '
  /^gpgsig /  { in_sig = 1; sub(/^gpgsig /, ""); print > ARMV; next }
  in_sig && /^ / { sub(/^ /, "");           print > ARMV; next }
  in_sig      { in_sig = 0 }
  { print > PAYV }
' ARMV="$TMP/sig" PAYV="$TMP/payload" "$TMP/raw"

# principal: default it to the one the trust file vouches for, read back the way
# git reads it (-Y find-principals); git never compares it to the committer
if [ $# -ge 2 ]; then
  PRINCIPAL=$2
else
  PRINCIPAL=$(ssh-keygen -Y find-principals -f "$ALLOWED" -s "$TMP/sig" 2>/dev/null | head -n 1 || true)
fi

ssh-keygen -Y verify -f "$ALLOWED" -I "$PRINCIPAL" -n git -s "$TMP/sig" < "$TMP/payload"

Run against the same commit that git reported as U, with the same trust file, it returns the whole story in one line and a status code:

ALLOWED_SIGNERS="$D/allowed_signers" ./c2cz-verify-one.sh c7cdae0
# Good "git" signature for ops@build-01 with ED25519 key SHA256:AAx1mhkUAuO11h4BLXksEpZps39Sh9Mvx9aUFPmOpqc
# rc=0

ALLOWED_SIGNERS="$D/allowed_signers" ./c2cz-verify-one.sh c7cdae0 ops@build-77
# Could not verify signature.
# rc=255

ALLOWED_SIGNERS="$D/allowed_signers" ./c2cz-verify-one.sh f188962
# Could not verify signature.
# rc=255

Note the last two results: a wrong principal and a commit signed by a key the file does not list are byte-identical here. ssh-keygen -Y verify hands you one bit — pass or fail — so the script's value is the exit code and the identity it prints on success, not the wording on failure.

Because the script uses git cat-file and ssh-keygen only, it works on a bare mirror clone with no gpg.format set — useful when the machine that audits signatures is not the machine that made them.

What each allowed_signers option actually does

The file is one entry per line: a principal, optional options, then the key. The options are documented in the ssh-keygen(1) man page under ALLOWED SIGNERS, and I tested each one against a real signed commit rather than trusting the reading. Results, all on OpenSSH 10.0p2:

EntryResultOutput
ops@build-01 ssh-ed25519 AAAA…rc=0Good "git" signature for ops@build-01 …
ops@build-01 namespaces="git" …rc=0 with -n gitGood "git" signature …
same entry, verified with -n filerc=255key is not permitted for use in signature namespace "file"
*@build-01 …rc=0wildcards work in the principal
ops@* …rc=0wildcards work on the host part too
ops@build-01,ci@runner-7 …rc=0verified as ci@runner-7 when that principal is requested
ops@build-01 valid-before="20300101" …rc=0accepted while the clock is before the cutoff
ops@build-01 valid-before="20200101" …rc=255key has expired: verify time 2026-10-13T21:33:28 > valid-before 2020-01-01T00:00:00
ops@build-01 valid-after="20200101" …rc=0accepted
… valid-after="20200101" valid-before="20300101" …rc=255invalid key — the space ends the options field, so the second option is read as the key type
… valid-after="20200101",valid-before="20300101" …rc=0one options field: both bounds apply together
… valid-after="20200101",valid-before="20250101" …rc=255key has expired: verify time … > valid-before 2025-01-01T00:00:00

The options field is comma-separated, and that is the trap worth pinning above your desk. Combining a validity window with an expiry cutoff is the natural thing to write when you harden a trust file, and written with a space it produces a line that no key in it can ever satisfy — rejected with invalid key before any signature is even considered, because the space ends the options field and the second option lands where the key type belongs. Order does not matter: swapping the two options fails the same way. Comma-joined, the same two bounds parse and are enforced together.

# rejected: "invalid key" (both option orders) - the space ends the options field
ops@build-01 valid-after="20200101" valid-before="20300101" ssh-ed25519 AAAAC3Nza…
# accepted: one comma-joined options field, both bounds enforced together
ops@build-01 valid-after="20200101",valid-before="20300101" ssh-ed25519 AAAAC3Nza…
# and two lines are not a window: a key passes if ANY line accepts it, so this
# pair still verifies against an entry whose valid-before has already expired
ops@build-01 valid-after="20200101" ssh-ed25519 AAAAC3Nza…
ops@build-01 valid-before="20200101" ssh-ed25519 AAAAC3Nza…

Practical reading of the rest: namespaces="git" is the cheapest hardening available, because it stops a signing key from being reused as a general-purpose file-signing key. Wildcards are convenient in a homelab and dangerous in a shared repository — *@build-01 trusts whoever holds any key that claims that suffix. Comma-separated principals are the right tool for one key used by a human and a runner: the verify output names whichever principal matched, so a log line tells you which identity was used.

The fix: three config keys and one file

For a single developer on a workstation, the whole configuration is this. Use the public key path, not the private one: git reads the .pub beside the private key when it needs to sign, and a key path with no .pub beside it still signs successfully — then fails verification later, in CI, where nobody is watching.

# 1. sign with the SSH key instead of GPG
git config --global gpg.format ssh

# 2. the key used to sign (public half is enough; keep the private half at 0600)
git config --global user.signingkey ~/.ssh/id_ed25519.pub

# 3. the trust file git verifies against
git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers

# 4. sign every commit from now on
git config --global commit.gpgsign true

# 5. the source of truth for the principal
git config --global user.email "ops@build-01"

Then create the trust file from the keys you actually hold, and verify before you trust the setup:

mkdir -p ~/.config/git
printf '%s %s\n' "$(git config user.email)" "$(cat ~/.ssh/id_ed25519.pub)" \
  > ~/.config/git/allowed_signers

git log --show-signature -1        # expect: Good "git" signature for ops@build-01 ...
git log --format='%h %G? %s' -5    # expect: G on every row you signed

For a team, the trust file belongs in the repository — conventionally .github/allowed_signers or .git-allowed-signers — so that every clone verifies against the same list, and CI can fail the build on a bad signature instead of a human eyeballing it.

git config gpg.ssh.allowedSignersFile ./.git-allowed-signers
git config commit.gpgsign true

Two behaviours to expect when you roll this out. Commits made before you configured signing stay unsigned: %G? reports N for them forever, and no config change can retro-fit a signature onto an existing object. And a signature that verifies cryptographically but whose key is not listed is reported as Good "git" signature … plus No principal matched. with exit 1 — a warning that looks like a success line in a green terminal, which is why the CI check below keys on the exit code alone.

A CI verifier that fails the build

This is the script I use, trimmed to what matters: it walks every commit reachable from a ref, rejects unsigned commits, extracts each armour correctly, and verifies it as the namespace git against the principal the trust file vouches for. It exits 2 on a usage or environment problem and 1 on a trust failure, so a pipeline can tell "your configuration is broken" from "this commit is not trustworthy".

#!/usr/bin/env bash
# verify-commits.sh — fail unless every commit reachable from REF carries an
# ssh signature made by a principal listed in the allowed_signers file.
set -euo pipefail

ALLOWED=${ALLOWED_SIGNERS:-.github/allowed_signers}
REF=${1:-HEAD}
BASE=${2:-}

[ -f "$ALLOWED" ] || { echo "verify-commits: allowed signers file not found: $ALLOWED" >&2; exit 2; }
command -v ssh-keygen >/dev/null 2>&1 || { echo "verify-commits: ssh-keygen not on PATH" >&2; exit 2; }
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "verify-commits: not a git work tree" >&2; exit 2; }

TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
rc=0; checked=0

if [ -n "$BASE" ]; then RANGE="$BASE..$REF"; else RANGE="$REF"; fi

for sha in $(git rev-list "$RANGE"); do
  # stream the object into a file: command substitution strips its trailing
  # newline(s) and re-hashes a commit whose message ends in a blank line
  git cat-file commit "$sha" > "$TMP/raw"
  if ! grep -q '^gpgsig ' "$TMP/raw"; then
    printf 'UNSIGNED %s %s\n' "${sha:0:12}" "$(git log -1 --format=%s "$sha")"
    rc=1
    continue
  fi
  awk '
    /^gpgsig /  { in_sig=1; sub(/^gpgsig /,""); print > ARMV; next }
    in_sig && /^ / { sub(/^ /,"");           print > ARMV; next }
    in_sig      { in_sig=0 }
    { print > PAYV }
  ' ARMV="$TMP/sig" PAYV="$TMP/payload" "$TMP/raw"
  # the principal is the one the trust file vouches for, read back the way git
  # reads it; git never compares it to the commit's committer address
  principal=$(ssh-keygen -Y find-principals -f "$ALLOWED" -s "$TMP/sig" 2>/dev/null | head -n 1 || true)
  if ssh-keygen -Y verify -f "$ALLOWED" -I "$principal" -n git \
       -s "$TMP/sig" < "$TMP/payload" >"$TMP/out" 2>&1; then
    printf 'OK       %s %s\n' "${sha:0:12}" "$principal"
  else
    printf 'FAIL     %s %s: %s\n' "${sha:0:12}" "${principal:-(no matching principal)}" \
      "$(tr -d '\r' < "$TMP/out" | awk 'NF{print; exit}')"
    rc=1
  fi
  checked=$((checked + 1))
done

echo "verify-commits: $checked signed commits checked, exit $rc"
exit "$rc"

Three details are not decoration. The output is captured with >"$TMP/out" 2>&1 because ssh-keygen -Y verify writes its reason to stdout — capturing stderr alone yields an empty reason, as measured above. tr -d '\r' strips the carriage return OpenSSH appends to diagnostics. And the loop trusts the exit code, never the text: No principal matched. arrives next to a Good "git" signature line.

Run against a history containing one commit signed by a key that was never added to the trust file, and one commit made with signing switched off:

ALLOWED_SIGNERS=./.git-allowed-signers ./verify-commits.sh HEAD
# OK       ce79488ab31c ops@build-01
# FAIL     f188962c3364 (no matching principal): Could not verify signature.
# OK       c7cdae015e7c ops@build-01
# UNSIGNED 7a71f2776b06 chore: unsigned commit
# verify-commits: 3 signed commits checked, exit 1

ALLOWED_SIGNERS=./.git-allowed-signers ./verify-commits.sh ce79488 f188962
# OK       ce79488ab31c ops@build-01
# verify-commits: 1 signed commits checked, exit 0

The base argument has to be the real parent, and this is where a plausible-looking range goes wrong: pass c7cdae0 as the base instead of f188962 and the untrusted commit is pulled back into scope — FAIL f188962c3364, 2 signed commits checked, exit 1. Wired into a workflow the range is what keeps the job fast: pass the merge base and only the incoming commits are checked, instead of the whole history on every push.

# .github/workflows/verify-commits.yml (excerpt)
# - run: |
#     BASE=$(git merge-base origin/main HEAD)
#     ALLOWED_SIGNERS=.github/allowed_signers ./verify-commits.sh HEAD "$BASE"

Rotating a key without rewriting history

Trust lives in the file, not in the commits, so rotation is additive. Add the new key as a second line, sign with it, and leave old commits verifiable by the old entry — removing the old line retroactively invalidates every commit it signed, which is usually the wrong move after a device change and the right move after a compromise. The shape of the problem is the same one that makes a changed host key so alarming: an identity you trusted yesterday is not the identity answering today, and only the trust list can tell you which.

# 1. new key, same principal — keep the old entry in place
ssh-keygen -t ed25519 -C "ops@build-01" -f ~/.ssh/id_ed25519_2026
printf '%s %s\n' "$(git config user.email)" "$(cat ~/.ssh/id_ed25519_2026.pub)" \
  >> .git-allowed-signers

# 2. sign with it
git config user.signingkey ~/.ssh/id_ed25519_2026.pub
git commit -S -m "build: rotate signing key"

# 3. both keys verify, and the log names which one was used
git log --format='%h %G? %GK %s' -3

If a key is genuinely compromised, rotating the trust file is not enough: the commits it already signed stay in history and keep verifying as G for anyone who has the old file. Drop the entry, add the new one, and treat the old commits as untrusted by policy — a signature proves authorship, not that the author is still who you thought.

Checklist

  • error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification plus No signature on a signed commit: the file is missing or empty, nothing else is wrong.
  • No principal matched. next to Good "git" signature …: the key is not in the file, or the principal you passed with -I is not the one listed. git reads the principal out of the file and never compares it to the commit's committer address.
  • Couldn't parse signature: missing header: the armour still carries git's gpgsig header prefix. Drop it and you will likely get missing footer instead, because the continuation lines are still folded with one leading space — both the keyword and the fold have to go.
  • Couldn't verify signature: namespace does not match: the blob was signed in another namespace. Commits use -n git, standalone payloads use whatever namespace was set at sign time.
  • invalid key on a line you just wrote: the options field is comma-separated, so a space between valid-after="…" and valid-before="…" ends the field and the second token is parsed as the key type. Comma-join them; splitting the two bounds across separate lines does not express a window, because a key is accepted if any line accepts it.
  • Check exit codes, not text. ssh-keygen -Y verify returns 255 on failure and writes the reason to stdout; git verify-commit returns 1 for an untrusted-but-valid signature.
  • Keep the private key at 0600 and confirm it, because the tooling will sign with a world-readable key without a word of complaint — the sibling failure mode is in the UNPROTECTED PRIVATE KEY FILE post.

Every failure in this article was produced on OpenSSH 10.0p2 with git 2.47.3 on arm64 Linux 6.17, from throwaway keys under /tmp; the scripts above were run in the order shown, and the printed lines are the ones they emitted. If your version predates ssh-keygen -Y sign (OpenSSH 8.0 and later, with gpg.format ssh support from git 2.34), the fallback is a GPG keyring and this whole set of messages disappears. When ssh-agent is the layer that breaks instead, that is a different failure with its own string — covered in the authentication agent post, and the reason a signing key that works interactively can still fail in a batch job.

Last verified: 2026-10-13 · OpenSSH 10.0p2, git 2.47.3, arm64 Linux 6.17.

C2CZ

Hyper-specific engineering guides: Cloudflare Workers, Turso, self-hosting, network security, offensive security, independent operations.