The short answer
Git has signed commits with X.509 certificates since version 2.19: set gpg.format to x509, point user.signingkey at a certificate that gpgsm on Linux or smimesign on Windows and macOS can reach, then commit with -S. The certificate that works is an S/MIME certificate. GitLab states the rule plainly: Key Usage has to include Digital Signature, and where an Extended Key Usage section exists at all it has to include emailProtection, with the address in the certificate matching the committer email. A code signing certificate fails that test twice over, which is why teams that sign both end up holding two certificates: one for commits, one for the binaries they ship.
On this page
- What a signed commit actually proves
- Which certificate actually verifies
- Why a code signing certificate is the wrong tool
- Setting it up with gpgsm on Linux
- Windows and macOS: smimesign, and its maintenance problem
- Checking the signature locally and on the host
- What expiry and revocation do to old commits
- X.509, GPG or SSH: picking one for a team
- FAQ
What a signed commit actually proves
A signed commit proves that whoever created it held a specific private key at the time. That is the entire claim. It does not review the code, it does not say the change is safe, and it does not stop anyone from writing a bad patch under their own real name. What it removes is the ability to forge authorship, which Git otherwise hands out freely.
The forgery problem is easy to demonstrate and hard to unsee. Author and committer fields in a Git commit are plain strings you type into your own config, so anyone with push access to a repository can produce a commit that claims to come from your colleague, your security lead, or a well-known maintainer. Nothing in Git objects. A signature is the only part of a commit that cannot be typed in.
Where the X.509 route differs from the alternatives is in whose claim about identity you are relying on. An SSH or GPG key asserts that the holder of key X made this commit, and the link between key X and a human being is whatever the hosting platform recorded when someone uploaded it. A certificate carries a name and an email address that a certificate authority checked against evidence before issuing, and the signature therefore carries that validation with it wherever the commit travels. If you have never looked at how that validation chain is built, our explanation of how digital signatures work with certificates covers the mechanics underneath this article.
Mechanically, Git stores the signature inside the commit object under a gpgsig header. Because the header is part of the object that gets hashed into the commit ID, altering a single byte of the tree, the message or the parent pointer invalidates the signature. That is why a signed commit also pins everything behind it.
Which certificate actually verifies
Use an S/MIME certificate: a personal certificate whose subject carries your email address and whose Extended Key Usage includes emailProtection. GitLab requires Digital Signature in Key Usage and emailProtection in any EKU section that is present, and the certificate email has to match both a verified account address and the committer email on the commit.
GitLab documents the failure mode in unusually direct language: setting values in the EKU section beyond the required Key Usage of Digital Signature is likely to cause commits to display as Unverified, and the resolution is to add emailProtection to that list. Read that sentence twice if you are holding a certificate issued for some other purpose. An EKU is not a hint about intended use that verifiers may ignore; it is a constraint they enforce.
GitHub approaches the same problem from the trust side. It verifies S/MIME signatures against the Debian ca-certificates package, the same trust store used by Mozilla browsers, and you never upload anything: the certificate travels inside the signature. The practical consequence is that a certificate from a publicly trusted CA works out of the box, while a certificate minted by your own internal CA cannot verify on GitHub at all, because nothing in that store issued it. Private CAs are fine for a self-hosted GitLab where you control the certificate store, and a dead end on the public platforms.
On the buying side this is an ordinary personal certificate rather than anything exotic. My-SSL issues S/MIME certificates in individual and business tiers through Certum, a publicly trusted CA whose roots are in the Mozilla store that GitHub checks against. The individual tier validates control of a single mailbox and is the usual fit for commit signing; the business tier additionally carries a validated company name in the subject, which is what an auditor asking for organisational attribution will want to see. If you are weighing the two, our S/MIME buying guide walks through what each validation level actually checks.
One detail worth checking before you order: the address you intend to commit under has to be the address in the certificate. That sounds obvious until you meet a developer whose Git config uses a@users.noreply.github.com address for privacy. No CA will issue against that domain, so privacy mode and X.509 commit signing are mutually exclusive. On GitLab 16.2 and earlier there is a related trap: where a certificate holds several addresses in its Subject Alternative Name list, only the first is used for matching.
Why a code signing certificate is the wrong tool
Reaching for the code signing certificate you already own is the most common wrong turn here, and it fails for two independent reasons. The EKU is codeSigning rather than emailProtection, so platform verification rejects it. And since 1 June 2023 the private key has been required to live in certified hardware, which a per-commit workflow cannot reach.
The hardware requirement is worth spelling out because it changes what is even possible. Under the CA/Browser Forum code signing requirements, the subscriber private key for a publicly trusted code signing certificate must be protected by a FIPS 140-2 Level 2 or Common Criteria EAL 4+ module, in practice a USB token or a cloud HSM, and it is generated non-exportable. Signing a release build three times a quarter through a token works fine. Touching that token for every commit on a busy afternoon does not, and nobody designs a workflow that way twice.
None of which makes code signing optional. The two certificates answer different questions at different points in the chain. A commit signature tells your repository who wrote a change; a code signing certificate tells an end user that the binary they downloaded came from you and has not been modified since, which is the part Windows SmartScreen and macOS Gatekeeper actually evaluate. A repository full of Verified commits still ships an unsigned installer that raises an unknown-publisher warning, and that is not a contradiction, just two layers doing their own jobs.
If you already hold a code signing certificate and want to know what it does and does not cover before you add a second certificate to the pile, our breakdown of code signing certificates versus SSL certificates lays out the boundaries of each certificate type in one place.
Setting it up with gpgsm on Linux
On Linux the tool is gpgsm, GnuPG’s S/MIME counterpart to gpg, and it is already installed wherever GnuPG is. Four commands cover the whole setup: import the PKCS#12 bundle your CA delivered, read back the certificate fingerprint, trust the issuing root so local verification works, and point Git at the result.
Start with the import. Your CA delivers the certificate and its private key as a .p12 or .pfx file, and gpgsm reads that format directly:
gpgsm --import [email protected]
gpgsm --list-secret-keysThe listing prints the certificate along with its serial number, validity dates, issuer and a 40-character fingerprint. That fingerprint is what Git wants. If the import complains about establishing trust, import the chain in issuing order first, root then intermediate then your own certificate, which keeps gpgsm from having to guess how the path is built.
Now the step people skip. gpgsm does not trust a root just because it is on disk; it consults ~/.gnupg/trustlist.txt, one fingerprint per line. Take the root fingerprint from gpgsm --list-keys and add it with the S flag, and the relax suffix if your root omits an extension gpgsm expects:
# ~/.gnupg/trustlist.txt
# one line per trusted root, fingerprint then flags
6F:5A:12:34:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89 S relaxThen wire up Git. The gpg.format setting is what tells Git to produce a CMS signature instead of an OpenPGP one, and user.signingkey takes the fingerprint you copied earlier:
git config --global gpg.format x509
git config --global user.signingkey 0FF455A2708394633E4BB2F88002E3CD80CBD76F
git config --global commit.gpgsign true
git config --global tag.gpgsign trueThe last two lines make signing the default so you stop remembering the -S flag, which matters more than it sounds: a branch where four commits out of nine are signed is harder to reason about than one where none are. If a revocation list fetch hangs on a locked-down network, add disable-crl-checks to ~/.gnupg/gpgsm.conf and understand the trade you just made: local verification stops noticing revocation, while the hosting platform keeps doing its own checks.
Windows and macOS: smimesign, and its maintenance problem
On Windows and macOS the documented tool is smimesign, which reads certificates from the operating system keystore rather than from a GnuPG home directory. Install it, list your keys, point Git at the certificate ID, and set gpg.x509.program alongside gpg.format so Git calls it instead of gpgsm.
# macOS
brew install smimesign
smimesign --list-keys
git config --global gpg.x509.program smimesign
git config --global gpg.format x509
git config --global user.signingkey 0ff455a2708394633e4bb2f88002e3cd80cbd76fUsing the OS keystore is the real advantage on these platforms. Your certificate sits where the rest of your identity material already sits, in Keychain Access or the Windows certificate store, which means the private key can be marked non-exportable and protected by the same login the machine already enforces. On Git 2.18 and earlier the setting is gpg.program instead, but a Git that old has other problems.
Now the part the official instructions do not mention. The last tagged release on the github/smimesign repository is v0.2.0-rc1 from October 2019, and the best-known community fork was archived by its owner in January 2025. The tool is not broken and the documentation still points at it, but a dependency whose upstream has been quiet for years is a planning problem rather than a today problem. On Windows there is no drop-in replacement worth naming, so the realistic options are to keep smimesign and watch it, run gpgsm under WSL for signing, or decide the X.509 identity is not worth the operational tail and use SSH signing instead.
One more Windows-specific note. If your certificate lives on a hardware token, smimesign can reach it through the Windows certificate store when the vendor’s middleware is installed, but expect a PIN prompt per signature. That is tolerable for tags and release commits and unbearable for daily work, which is the same friction that rules out token-based signing keys for this use case in the first place.
Checking the signature locally and on the host
Verify before you push, not after. Two commands cover it: git log --show-signature prints the verification result beside each commit, and git verify-commit HEAD exits non-zero when the signature does not check out, which makes it usable in a pre-push hook or a pipeline step.
git log --show-signature -1
git verify-commit HEAD
# every commit on this branch that is not signed
git log --pretty="%h %G? %an %s" origin/main..HEADThat third command is the useful one for a team. The %G? placeholder prints a single character per commit: G for a good signature, B for a bad one, U for good with unknown trust, X for good but expired, and N for no signature at all. Scanning a column of those tells you in one glance whether a branch is consistently signed, and U appearing everywhere is almost always the missing trustlist entry rather than anything wrong with the certificate.
The hosting platform runs its own check at push time and that check can disagree with yours in both directions. A commit can be good locally and Unverified on GitHub because the committer email does not match the certificate. It can be Verified on GitHub and untrusted locally because you never added the root to trustlist.txt. Test both paths once, on a scratch repository, before you tell a team to turn signing on.
What expiry and revocation do to old commits
Old commits keep their badge. GitHub stores a verification record beside a commit the first time the signature checks out, including a verified_at timestamp readable through the REST API, and that record cannot be edited, so signatures stay verified over time even after keys are rotated or revoked and contributors leave.
Local verification answers a different question and gives a different result. git verify-commit hands the signature to gpgsm, which evaluates the certificate against today: expired yesterday means expired now, and %G? starts returning X for commits that GitHub still shows as Verified. Teams hit this a few weeks after their first rotation and conclude something is broken. Nothing is. The platform recorded a judgement made at push time; your laptop is making a fresh one.
The operational consequence is that rotation is cheap here, which is not true everywhere in the certificate world. When your S/MIME certificate approaches expiry you import the new one, update user.signingkey to the new fingerprint, and carry on; nothing needs re-signing and no history needs rewriting. Keep the old certificate in your gpgsm store rather than deleting it, so local verification of older commits still has something to build a path with, and put the expiry date wherever the rest of your certificate expiry monitoring lives. A signing certificate that expires quietly stops producing signatures at exactly the moment someone is trying to cut a release.
X.509, GPG or SSH: picking one for a team
Git accepts three signature formats and the choice comes down to who vouches for the identity. SSH signing, supported since Git 2.34, reuses the key you already push with and needs no new infrastructure. GPG needs a web of trust or a key server nobody maintains. X.509 brings a validated identity that a certificate authority stands behind.
For most teams SSH is the pragmatic answer and the honest recommendation. Setup is a config line and an upload, every developer already has a key, and the platform badge looks identical to the one X.509 produces. What you give up is meaning: the platform is attesting that a key registered to an account signed the commit, and if the account was created last Tuesday with a throwaway address, the badge says exactly that much and no more.
X.509 earns its extra work when the identity has to withstand questions from outside the platform. A regulated supplier proving who authored a change, a vendor meeting supply-chain attestation requirements, an organisation that wants its legal name inside the signature rather than inside a profile field: in all three cases the value sits in the validation the CA performed, and the signature carries that validation with it even if the repository is later mirrored somewhere else. That portability is the thing SSH cannot offer at any price.
A practical middle path exists and more teams should use it. Use SSH signing for day-to-day commits, and X.509 for signed tags on releases, where the count is low, the ceremony is appropriate and the audience is widest. Git takes gpg.format per repository, so a release repository can differ from the rest without anyone having to remember a flag.
FAQ
Frequently Asked Questions
Answers to common questions about certificates and our services.
Can I use my code signing certificate to sign Git commits?
In practice, no. GitLab requires Key Usage to include Digital Signature and, where an Extended Key Usage section exists at all, requires emailProtection to appear in it; a code signing certificate carries codeSigning instead, so the commit renders as Unverified. There is a second obstacle underneath the first. Since 1 June 2023 the CA/Browser Forum has required the private key of a publicly trusted code signing certificate to live in a FIPS-validated token or HSM, and neither gpgsm nor a plain Git workflow reaches into that hardware for every commit. Sign commits with an S/MIME certificate and keep the code signing certificate for the binaries you ship.
Why does GitLab show my X.509 signed commit as Unverified?
Work through four causes in order. The certificate email has to match a verified email address on the GitLab account, and it has to be the committer email on the commit itself. The Extended Key Usage section, if present, has to include emailProtection, which is where certificates carrying other purposes quietly fail. GitLab needs a complete chain from the signing certificate to a root in its own certificate store, so a private CA needs its root added to that store. And on GitLab 16.2 and earlier, only the first email in the Subject Alternative Name list is used, so a certificate holding several addresses may be matched on the wrong one.
Do I need to upload my certificate to GitHub first?
No, and this is the one place where X.509 signing is less work than GPG or SSH. GitHub verifies S/MIME signatures against the Debian ca-certificates package, the same trust store used by Mozilla browsers, so a certificate from a publicly trusted CA needs no upload at all. A certificate from your own internal CA is the opposite case: nothing in that public trust store issued it, so GitHub cannot build a path to a trusted root and the commit stays Unverified no matter how correct the signature is.
Is smimesign still maintained?
It still works and it is still the path GitHub documents for Windows and macOS, but treat it as frozen rather than current. The last tagged release on github/smimesign is v0.2.0-rc1 from October 2019, and the best-known community fork was archived in January 2025. That matters for planning rather than for today: if your toolchain updates around a project that has not shipped in years, the safer long-term option is gpgsm, which ships with GnuPG and is maintained alongside it, or a move to SSH signing if the X.509 identity is not the point.
Do my signed commits break when the certificate expires?
On GitHub they do not. When a signature is verified at push time GitHub stores a verification record next to the commit, including a verified_at timestamp visible through the REST API, and that record cannot be edited, so commits stay Verified after the signing certificate is rotated, expires or is revoked. Local verification behaves differently: git verify-commit re-evaluates the certificate every time you run it, so gpgsm will report an expired certificate for a commit GitHub still shows as Verified. Both answers are correct, they are just answering different questions.
Should a team standardise on X.509 or SSH commit signing?
Pick SSH signing if you only need the platform to say a known contributor pushed the commit, because Git has supported it since 2.34 and the key you already use to push doubles as the signing key. Pick X.509 if the identity behind the signature has to be an audited one: a certificate carries a validated name and organisation that a CA checked, which is what a supply-chain or regulatory reviewer asks for. Teams under an audit requirement usually end up on X.509 for that reason alone, and the rest are better served by SSH.
Getting the certificate this guide needs
Commit signing works with an ordinary personal S/MIME certificate, provided it chains to a publicly trusted root and carries your committer address. My-SSL issues S/MIME certificates through Certum, a certificate authority whose roots ship in the Mozilla store that GitHub checks signatures against, in an individual tier for a single mailbox and a business tier that adds a validated company name to the subject. The same certificate signs your email.