Who actually signed this commit? How I get a human, an AI agent, and a bot to commit as themselves
A signature answers one question: who produced this change? On a one-person platform that leans hard on automation, that question has more than one honest answer. I commit by hand. An AI coding tool commits on my behalf, all day. A dependency bot opens dozens of merge requests a week. If all three showed up in the history as “me,” the audit trail would be a comfortable fiction — and the whole point of signing is that it isn’t fiction. So each actor signs as itself, with its own key — and one rule decides how much rope each one gets: Signing proves identity. It does not grant authority.commit-autonomy ≠ publish-authority. An identity can be trusted to author a change without being trusted to release it. That split is what lets an agent work unattended while a stolen key — or a compromised bot — still can’t cut a version.
The cast: three identities, three keys
| Identity | Role | Mechanism | Status |
|---|---|---|---|
| Kai (me) | Human | ed25519-sk, resident on a YubiKey, PIN + touch per signature, private key never leaves hardware | Verified |
| KOH Coding Agent | AI coding agent | ed25519, software key on a dedicated service account, token scoped to write_repository only | Verified |
| KOH Renovate Bot | Dependency bot | GPG instead of SSH (because of API commits), private key in a protected CI variable | Verified |
The human gets the strictest standard, and everything else is defined as an exception to it. The AI writes a lot of code but must not show up as me — and must not publish on its own authority either. The bot opens dozens of merge requests a week and signs every one of them, but through a technically different path than the other two, because it commits through the API instead of a local git commit.
Part I — The human: a key that lives in hardware
The human standard is the strictest, and everything else is defined as an exception to it. My signing key is a resident credential generated directly on a YubiKey, with verify-required set — that forces a PIN and a physical touch on every single signature, and the private key material never lands on the workstation at all. The property that buys: hardware you don’t have is hardware you can’t sign with. Even a stolen, unlocked laptop isn’t enough.
macOS detour
The first attempt failed instantly: Key enrollment failed: invalid format. Apple’s bundled ssh-keygen ships without a FIDO provider — it simply can’t talk to a security key. The fix is Homebrew’s OpenSSH, built against libfido2: the security-key logic lives in its ssh-sk-helper, and once that’s on PATH, resident-key generation just works.
# FIDO-capable ssh-keygen
brew install openssh
# resident key created ON the YubiKey; PIN + touch enforced per use
ssh-keygen -t ed25519-sk -O resident -O verify-required \
-f ~/.ssh/signing_sk -C "you@example.com"
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/signing_sk.pub
git config --global commit.gpgsign true
Then the public key goes into GitLab as an SSH signing key (distinct from an auth key — GitLab tracks the usage type separately), and the committer email in your git config has to be a confirmed address on that account. Miss either and the signature is valid but shows Unverified. A repo-level allowed_signers file lets git verify-commit and CI checks verify offline, without trusting the server:
# .gitsigners / allowed_signers — one line per trusted signer
you@example.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
# verify locally, without asking GitLab
git config gpg.ssh.allowedSignersFile ~/.gitsigners
git log --show-signature -1
Retiring the old key
I’d been signing with a plain on-disk ed25519 key. Once the hardware key was live, I dropped the old one from signing entirely — kept only for authentication — so there’s exactly one thing in the world that can author a commit as me, and it’s a physical object. A resident key is also portable: ssh-keygen -K re-derives it onto any machine that has the YubiKey.
Part II — The agent: autonomy without a person
An AI coding tool writes a lot of my code. It cannot touch a YubiKey, and it must not borrow my identity — work an agent produced should say so. So it gets its own: a dedicated GitLab service account (“KOH Coding Agent”) with a confirmed email, a software ed25519 signing key registered on that account, and a token scoped to write_repository and nothing more.
# plain software key: no passphrase, usable unattended
ssh-keygen -t ed25519 -f ~/.ssh/agent -N "" -C "agent@example.com"
# register ~/.ssh/agent.pub as a SIGNING key on the service account
The publish gate
This is where commit-autonomy ≠ publish-authority becomes real config. The agent holds the Developer role — it can branch, push, and open merge requests. It cannot:
- merge to a protected branch (that’s Maintainer + code-owner approval — me, with the YubiKey);
- create a protected version tag, or write to the package registry;
- reach the release-signing key (that’s
cosignplus a KMS key it has no access to).
Fully compromised, the worst it can do is produce reviewable, unmerged feature branches. Authorised automation with a real job — Renovate, the release bot — keeps its higher privileges; the restriction is enforced by role level, not a blanket ban on bots.
Making it always sign as itself
Here’s the knot that took two tries. I first pointed the agent at its own global git config via GIT_CONFIG_GLOBAL — and commits still came out under my email. The reason: a lot of repos pin a local user.email in .git/config, and local config outranks a swapped-in global one. The agent was signing correctly but attributing to the wrong account, which reads as Unverified for that identity.
The fix is to inject config at the environment level, which carries the same precedence as git -c — the highest there is, above every config file:
export GIT_AUTHOR_NAME="Agent" GIT_AUTHOR_EMAIL="agent@example.com"
export GIT_COMMITTER_NAME="Agent" GIT_COMMITTER_EMAIL="agent@example.com"
# GIT_CONFIG_* env == `git -c`, beats local/global/system files
export GIT_CONFIG_COUNT=3 \
GIT_CONFIG_KEY_0=user.signingkey GIT_CONFIG_VALUE_0=~/.ssh/agent.pub \
GIT_CONFIG_KEY_1=gpg.format GIT_CONFIG_VALUE_1=ssh \
GIT_CONFIG_KEY_2=commit.gpgsign GIT_CONFIG_VALUE_2=true
Applying that everywhere would be wrong, though — I don’t want the agent identity leaking into an unrelated repo, or bleeding into my own terminal. So it’s scoped by a pre-commit tool hook: before the agent runs git commit / tag / push, the hook checks the repo’s remote, and only injects the identity when the remote is my host. Everywhere else — and in my hands-on terminal, which the hook never sees — nothing changes.
cmd=$(jq -r '.command')
# only git commit/tag/push, only my host
echo "$cmd" | grep -qE 'git .*(commit|tag|push)' || exit 0
git -C "$dir" remote -v | grep -q 'git.example.com' || exit 0
# rewrite the command with the agent env prepended, then allow it
emit_updated_command "$AGENT_ENV $cmd"
Pushes use the same scoping: a host-pinned credential helper hands git the agent’s scoped token, so the bot pushes as the bot — and if that token ever expires, pushes fail loudly instead of silently falling back to my credentials.
Part III — Renovate: a bot that signs its own updates
Renovate is the awkward case, and the reason is mechanical. By default it creates commits through the GitLab API — they’re built server-side, where there is no local git commit to hook a key into. So no matter what key you give it, the commits land unsigned. The fix is two settings and a key handed to it the long way round.
First, force it to commit locally so a signature can actually be applied; then hand it a GPG key whose UID equals the bot’s confirmed email. The public half goes on the bot’s GitLab account under GPG keys; the private half lives only in a protected CI variable.
# the UID email MUST be a confirmed address on the bot account
gpg --quick-generate-key "Renovate Bot <bot@example.com>" ed25519 sign never
# public key → bot's GitLab "GPG keys"
# armored private key → protected CI var: RENOVATE_GIT_PRIVATE_KEY
variables:
RENOVATE_GIT_AUTHOR: 'Renovate Bot <bot@example.com>' # must equal the GPG UID
RENOVATE_PLATFORM_COMMIT: 'false' # commit locally, so it can sign
# Renovate maps RENOVATE_GIT_PRIVATE_KEY → config.gitPrivateKey automatically
The trap to avoid: if the author email and the GPG key’s UID don’t match — or the email isn’t confirmed on the account — GitLab quietly marks every one of those commits Unverified, with no error. And one detail that seemed like trivia at the time: GPG signatures are verified through a different code path than SSH. Hold that thought — it matters in the next part.
Part IV — The day every green check went dark
I finished wiring the hardware key, made a test commit, and opened it in GitLab. Unverified. So was the agent’s. So was a commit I’d signed the day before with the old key. Three different keys, two identities — all rejected at once. Locally, git was perfectly happy:
git log --show-signature
Good "git" signature for you@example.com with ED25519 key
# … and GitLab says: Unverified
Cryptographically valid locally. Rejected server-side. That gap is the whole mystery.
I checked the obvious things, twice. The key was registered as a signing key. The committer email was confirmed and mapped to the right account. The signature verified locally. GitLab even identified the right key and the right user — it just refused to call it verified. The docs list exactly one reason a valid SSH signature shows Unverified: the committer email doesn’t match a verified address. Which didn’t apply.
So I stopped guessing and read the source. GitLab Enterprise ships an override on the SSH check that runs before the normal logic:
# EE override — the CA check gets first say
def calculate_verification_status
ca_verification_status || super
end
# and that CA service:
if verified? then :verified_ca
elsif root_namespace.enforce_ssh_certificates? then :unverified
end
There it was. A top-level group had enforce_ssh_certificates switched on — but with no certificate authority actually configured behind it. So for any commit signed with an ordinary key, the enforcement check returned :unverified and short-circuited the entire thing, before GitLab ever looked at the key or the email. It was never my setup. It was one group setting, silently overriding everybody.
Worth flagging for context: this exact feature — group-wide SSH certificate enforcement backed by a certificate authority — is a Premium/Ultimate feature and currently GitLab.com (SaaS) only, not available on self-managed instances. Self-managed setups have a separate, instance-level SSH certificate feature (gitlab-sshd) that works differently. If you’re on Community Edition or an older self-managed instance, you won’t hit this exact bug — the general lesson (“read verification overrides that sit above the actual check”) still applies.
Two twists. First, it was on two top-level groups — I only caught the second when a bot commit in a different group stayed red after I’d “fixed” it. Second, the enforcement toggle itself isn’t in the REST API at all — there’s a documented group API for the CA certificates themselves, but flipping enforcement on or off currently only surfaces in Settings → General → Permissions or the Rails console. And existing commits keep a cached verdict, so flipping the setting isn’t enough — you have to invalidate the stored signatures for anything already looked at.
The fix
Turn enforce_ssh_certificates off on every top-level group that has no CA behind it, then bust the cached CommitSignature records for the affected commits so they re-verify. New commits recompute on their own. Everything went green in the same second — human, agent, and old key alike.
And Renovate? Never affected. GPG rides a different verification path entirely, untouched by the SSH-certificate check. The one time “SSH and GPG take different routes” stopped being trivia and started being the reason a whole class of commits was fine while another wasn’t.
Verifying the whole thing
Two levels of check, and they can disagree — which is exactly what sent me into the war story above. Locally, against your own allowed-signers list:
git log --show-signature -1 # Good "git" signature for …
git verify-commit HEAD
Server-side, GitLab shows a Verified badge on the commit, and exposes the same status over the API. When those two disagree despite a valid local signature, don’t re-generate keys — the key is fine. Look at the server’s verification path: enforcement settings, cached verdicts, the email-to-account mapping. The docs describe the happy path; the code has the edge cases.
Frequently asked questions
Do I strictly need a YubiKey, or is a software key enough for the human part?+
For the strictest protection: yes, hardware. A software key on disk is better than no signature at all, but it shares the fate of your workstation — if that's compromised, someone can commit as you. The core property of a resident key with verify-required is that a PIN plus a physical touch are required, and the private key never leaves the hardware. For the AI agent and bot identities, on the other hand, a software key is the right choice, since they need to run unattended.
Does this setup also work with GitHub instead of GitLab?+
The underlying principle — a distinct identity per actor, signing separated from publish authority — is platform-independent. The concrete mechanisms differ, though: GitHub also supports SSH and GPG commit signatures, but has no direct equivalent to the GitLab group-wide SSH certificate bug described here. Renovate on GitHub typically also commits through the platform API, so the same gitPrivateKey/local-commit logic applies.
Is the group-wide SSH certificate enforcement that caused the bug only available on certain GitLab editions?+
Yes. This specific feature (group owners configure a certificate authority that replaces SSH keys and access tokens for regular user accounts) is a Premium/Ultimate feature and currently GitLab.com (SaaS) only, not available for self-managed instances. Self-managed setups have a separate, instance-wide SSH certificate feature (gitlab-sshd) that works differently. If you only run Community Edition, you can't hit this exact bug — but you should still check whether other verification overrides apply similarly.
What happens if the Renovate bot's GPG key is compromised?+
A stolen signing key alone doesn't grant publish authority — the bot account remains bound by its role (typically Developer, with merge rights limited to non-protected branches for dependency updates). An attacker could use it to open signed but maliciously altered merge requests, which would still have to pass review before reaching a protected branch. Rotation is still mandatory: generate a new GPG key, remove the old one from the bot account, replace the CI variable.
Can the coding agent grant itself higher privileges if its token is compromised?+
No, not through git mechanisms alone. Its token is scoped to write_repository, its role is Developer — both enforced on GitLab's side, not controlled by the agent itself. Merging to protected branches requires the Maintainer role plus code-owner approval, release tags and the package registry are separately protected, and the release-signing key sits behind a KMS the agent account has no access to. Worst case, it produces reviewable, unmerged branches.
Do I have to maintain the allowed_signers file by hand?+
Yes, for local/CI offline verification — GitLab itself manages the server-side mapping of key to account automatically once a signing key is registered, but an allowed_signers file in the repo is a separate, git-native list you maintain yourself (or generate by script from the registered signing keys of allowed accounts), so that git verify-commit and CI checks work without asking the server.
Conclusion
Three identities. Three keys. One rule that decides what each is trusted to do. The green check is nice — but the point was never the badge. It's that the history tells the truth about who did what, and no single key can quietly ship a release on its own.
What I'd keep
- Give automation its own identity. A bot signing as a human turns your audit log into fiction. One service account per non-human actor keeps provenance honest — and attribution lives in the Author field, not a
Co-Authored-Bytrailer. - Separate signing from shipping. Let identities author freely; gate publishing — merges, tags, release signatures — behind a human with a hardware key. A compromised agent should top out at an unmerged branch.
- Match the mechanism to the actor. Humans get hardware. Agents get scoped software keys, injected at env precedence so a repo-local
user.emailcan't hijack them. A bot committing through an API needs GPG and a local commit, or nothing signs. - When verification lies, read the source. The docs listed one failure mode; the code had two, and a cache on top. An hour in the verifier beat a day of re-issuing perfectly good keys.
I set up signing pipelines for humans, AI agents, and bots so the history stays honest and no single key can ship a release on its own.
YubiKey resident keys, scoped service accounts, GPG signing for API-committing bots, and the GitLab verification edge cases the docs don't mention.
Platform operations, not paper-based consulting: I set up, harden, and debug your signing pipeline on an ongoing basis.
About the author
![[Translate to English:] Foto von Kai Ole Hartwig.](/fileadmin/_processed_/e/9/csm_ole-neu_73323ad80d.jpeg)
Kai Ole Hartwig
Programming since 2002 – self-taught, set up my own business with KO-Web in 2012. Over 100 projects, with a focus on security, performance, automation and quality. Today freelance: DevSecOps consulting, training and software development.