Building an allowed signers file from team membership Jump to heading

SSH commit signatures are verified against a plain text file that maps an identity to a public key. That file is the whole trust model: whoever appears in it can produce commits your gates will accept, and whoever is missing cannot. Most teams start by hand-editing it, and within a quarter it contains a contractor who left in spring, misses two new hires, and has one engineer listed under an email they no longer commit with. The fix is to stop treating it as a document and start treating it as build output β€” generated from the roster you already maintain, regenerated on a schedule, and reviewed as a diff. This page shows that pipeline and belongs with the other commit verification gates.

When to use this approach Jump to heading

  • You verify SSH signatures anywhere outside the forge β€” in a pre-receive hook, a CI job, or an audit script β€” and need a trust file for it to read.
  • Membership already lives somewhere authoritative: a forge team, an identity provider group, or a directory export.
  • Contributors register their signing keys with the forge, so the public halves are retrievable over an API.
  • People join and leave often enough that a hand-edited file drifts within weeks.
  • If you sign with GPG instead, the same idea applies to a keyring, but the file format below is SSH-specific; the trade-offs are in GPG vs SSH commit signing.

Step 1 β€” Understand the file format before generating it Jump to heading

Each line is a principal list, optional options, and a public key. The principal is what Git compares against when it reports who signed, and it is usually the committer email.

# principals                options                                    key
[email protected]           namespaces="git",valid-after="20260101"    ssh-ed25519 AAAAC3Nz...
[email protected],[email protected]  namespaces="git"                           [email protected] AAAAGnNr...

Three options matter. namespaces="git" stops a key that signs other things from being replayed as a commit signature. valid-after and valid-before take dates (or timestamps) and bound when a signature by that key counts. Multiple principals can share a key, separated by commas, which is how you cover an engineer who commits under two addresses.

# Verification: check a line parses by verifying a known-good commit against it
git -c gpg.ssh.allowedSignersFile=./allowed_signers verify-commit HEAD

Step 2 β€” Pull the roster and the registered keys Jump to heading

The generator needs two lists: who is in the team, and which signing keys each of those people has registered. On GitHub both are available through the API; the same shape exists on GitLab and Gitea.

#!/bin/sh
# gen-allowed-signers.sh β€” prints a fresh allowed_signers file to stdout
set -eu
org=example; team=engineering

gh api --paginate "/orgs/$org/teams/$team/members" --jq '.[].login' |
while read -r login; do
  email=$(gh api "/users/$login" --jq '.email // empty')
  # Prefer the verified commit email from your directory if the profile hides it
  [ -n "$email" ] || email=$(grep "^$login " roster-emails.txt | cut -d' ' -f2)
  gh api "/users/$login/ssh_signing_keys" --jq '.[].key' |
  while read -r key; do
    printf '%s namespaces="git" %s\n' "$email" "$key"
  done
done | sort

Two details are worth the care. Public profile emails are often hidden, so keep a small mapping from login to commit email, fed from your identity provider. And only fetch signing keys, not authentication keys: many people use different keys for each, and an authentication key in the trust file would make every server they can log into a potential signing oracle.

From roster to trust fileTeam membership and each member's registered signing keys are fetched from the forge, joined with a login-to-email map, written as allowed signers lines, and committed so every change appears as a reviewable diff.Team roster/teams/…/membersSigning keys/ssh_signing_keysEmail maplogin β†’ commit emailallowed_signerssorted, namespacedPull requestdiff reviewedthe diff is the audit trail: every added or removed key has a reviewer
# Verification: the output has one line per key and no blank principals
./gen-allowed-signers.sh > allowed_signers.new
awk '$1=="" || $1 !~ /@/' allowed_signers.new | wc -l   # expect 0

Step 3 β€” Keep departed keys with a closing date instead of deleting them Jump to heading

Deleting a leaver’s line breaks verification of every commit they ever made, which turns every historical audit red. Close the window instead: keep the key and add valid-before set to their last day. New signatures by that key stop counting; old ones still verify.

# retired-signers.txt β€” maintained by hand, appended to the generated output
# carol left 2026-08-29
[email protected] namespaces="git",valid-before="20260830" ssh-ed25519 AAAAC3Nz...
# In the generator, after the live roster:
cat retired-signers.txt

The same mechanism handles key rotation. When someone replaces a key, the old key gets a valid-before date and the new one a valid-after, and both stay in the file. That matters for historical checks; it does not matter for a gate that only looks at new commits, which will reject the old key either way once its window closes.

One engineer's keys over two yearsAn engineer joins and registers a laptop key, rotates it a year later, and eventually leaves. Each key keeps its line in the trust file with a validity window, so every commit they made still verifies against the key that was valid at the time.Joinslaptop key added2025-02Rotationold key: valid-before2026-02New keyvalid-after set2026-02Leavesnew key: valid-before2026-08Auditall history verifiesany timeclosing a window is reversible and keeps history green; deleting a line is neither

Step 4 β€” Commit the file and regenerate it on a schedule Jump to heading

The generated file should live in a repository β€” ideally a small one owned by the security or platform team β€” so that each regeneration produces a diff. A scheduled job runs the generator, and if the output changed, opens a pull request rather than pushing directly.

# .github/workflows/allowed-signers.yml
on:
  schedule: [{ cron: "17 6 * * 1-5" }]
  workflow_dispatch:
permissions: { contents: write, pull-requests: write }
jobs:
  regenerate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./gen-allowed-signers.sh > allowed_signers
        env: { GH_TOKEN: "${{ secrets.ORG_READ_TOKEN }}" }
      - run: |
          git diff --quiet allowed_signers && exit 0
          git switch -c signers/$(date +%F)
          git commit -am "Regenerate allowed_signers from team roster"
          git push -u origin HEAD
          gh pr create --fill --label security
        env: { GH_TOKEN: "${{ github.token }}" }

The token that reads team membership needs only organisation read scope. Keep it separate from the workflow token, which only needs to push a branch and open a pull request in this one repository.

# Verification: a no-change run exits cleanly and opens nothing
gh workflow run allowed-signers.yml && gh run watch
gh pr list --label security --state open

Step 5 β€” Distribute the file to every verifier Jump to heading

The file is useless until the verifiers read the same version. Three consumers are common, and each pulls it differently.

Where the trust file is consumedA server hook reads it from a path on the Git host, CI jobs fetch it at a pinned commit, and developer machines point their local configuration at a checked-out copy so that git log --show-signature agrees with the gates.Server hookcopied on mergeread-only pathCI verifierfetched at a pinnedcommit per runDeveloper machinelocal checkoutgpg.ssh configone source, three readers β€” a stale copy anywhere produces confusing disagreement
# Developer machines: point Git at a checked-out copy
git clone [email protected]:security/allowed-signers.git ~/.config/git/signers
git config --global gpg.ssh.allowedSignersFile ~/.config/git/signers/allowed_signers

# CI: fetch a pinned revision so a run is reproducible
curl -fsSL "https://git.example.com/security/allowed-signers/raw/$SIGNERS_REV/allowed_signers" \
  -o "$RUNNER_TEMP/allowed_signers"
# Verification: every consumer reports the same checksum
sha256sum ~/.config/git/signers/allowed_signers
ssh [email protected] sha256sum /etc/git/allowed_signers

⚠️ SAFETY WARNING: The trust file is a security boundary. Anyone who can merge to its repository can grant themselves the ability to produce β€œverified” commits everywhere the file is consumed. Protect that repository with required review from a small security group, require signed commits on it, and never let the regeneration job merge its own pull request. If a bad entry does land, revert the commit (git revert <sha>) and redistribute immediately β€” reverting restores the previous trust set without rewriting history.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Can the principal be a login instead of an email? Jump to heading

Git compares the principal against the identity it is asked to verify, and for commits that means the committer email reported by %GS checks. Using logins would make every signer mismatch their commits. Keep emails as principals and put the login in a trailing comment if you want it for humans.

How do we handle bots and the forge’s own merge key? Jump to heading

Add them in a separate hand-maintained block appended after the generated roster, each with its own descriptive principal. They are not team members, so they should not come from the team API, and keeping them visibly separate makes them easier to review.

What happens if the API is down during regeneration? Jump to heading

The generator must fail rather than emit a short file. Use set -eu, check that the member count is above a sane floor, and refuse to write output if any API call failed β€” an empty trust file would reject every commit, and a partial one would silently drop people.