Skip to main content

    How to Sign Git Commits with an X.509 Certificate

    Git signs commits with X.509 through gpgsm or smimesign. Which certificate verifies on GitHub and GitLab, the emailProtection trap, and the setup.

    MS
    My-SSL Team
    ·
    14 min read
    ·
    Published September 29, 2026
    ·
    Last updated September 29, 2026

    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.

    The path from an X.509 certificate to a Verified badge on a Git hosting platformA four-stage flow read left to right. Stage one, highlighted in gold, is the certificate itself and the three properties that decide everything downstream: Key Usage must include Digital Signature, the Extended Key Usage section must include emailProtection where it exists at all, and the email in the Subject Alternative Name must match the committer address. Stage two is the signing step, where git commit dash capital S calls gpgsm on Linux or smimesign on Windows and macOS, and the resulting CMS signature is stored in the commit object under a gpgsig header. Stage three is the platform check: GitHub and GitLab rebuild the chain from the signing certificate to a trusted root and compare the certificate email against the committer email. Stage four is the result, a Verified badge, or Unverified when any single check fails. A footer states the limit of the whole mechanism: a signature proves who created the commit, not that the code inside it is safe.Three certificate fields decide whether a commit reads as Verified1. THE CERTIFICATEan S/MIME certificateKey UsageDigital SignatureExtended Key UsageemailProtectionSAN email= your committer email2. THE SIGNING STEPgit commit -SgpgsmLinux, ships with GnuPGsmimesignWindows and macOSCommit objectgpgsig header carriesa detached CMS signature3. THE PLATFORM CHECKChain to a trusted rootGitHub: Debian ca-certificatesEmail matchcertificate vs committerSignature arithmeticover the commit objectVerifiedall three passUnverifiedany one failsA signature answers who, never what.Verified means the commit was made by the holder of a certificate that a CA validated and theplatform trusts. It says nothing about whether the change inside the commit is correct, reviewedor safe, and no signing scheme has ever claimed otherwise.
    Every failure people report on this topic lands in stage one. The tooling in stage two rarely goes wrong; the certificate chosen in stage one does.

    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.

    S/MIME certificate, code signing certificate and SSH key compared for Git commit signingA comparison table with three columns and five rows. The S/MIME certificate column is highlighted in gold as the option that works for commit signing. Row one, what identifies you: the S/MIME certificate carries a validated name and email address, the code signing certificate carries a validated organisation but no email, and the SSH key carries only a public key with no validated identity. Row two, Extended Key Usage: emailProtection for S/MIME, codeSigning for the code signing certificate, and not applicable to an SSH key. Row three, where the private key lives: a file or a software keystore for S/MIME, a FIPS validated token or hardware security module for code signing since June 2023, and a file or agent for SSH. Row four, commit verification on GitHub and GitLab: verified for S/MIME, unverified for a code signing certificate because of the Extended Key Usage rule, and verified for SSH once the key is registered on the account. Row five, what the credential is really for: signing mail and commits, signing shipped binaries and installers, and authenticating to servers.Three credentials developers already own. Only one signs commits.S/MIME certificateCode signing certSSH keyIdentifies you asvalidated name + emailorganisation, no emailnothing validatedExtended Key UsageemailProtectioncodeSigningnot applicablePrivate key lives infile or software keystoreFIPS token or HSMfile or ssh-agentCommit shows asVerifiedUnverified (wrong EKU)Verified, key uploadedActually built formail and commitsshipped binariesserver authenticationThe code signing column is not a weaker choice, it is a different job. Most teams that sign releasesproperly end up holding two certificates, one per column, and that is the intended shape.
    Row four is the whole argument. Everything above it explains why that row reads the way it does.

    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.

    The five-step order for configuring gpgsm to sign Git commitsA horizontal five-step flow. Step one: import the PKCS number twelve bundle with gpgsm dash dash import, which loads the certificate and its private key. Step two: list secret keys to read back the certificate fingerprint. Step three, highlighted in gold as the step that is skipped most often: add the issuing root fingerprint to the GnuPG trustlist file so that gpgsm can verify its own signatures locally. Step four: configure Git by setting gpg dot format to x509 and user dot signingkey to the fingerprint from step two. Step five: commit with the dash capital S flag, or set commit dot gpgsign to true once and stop thinking about it. A footer notes that skipping step three does not stop signing, only local verification, which is why the mistake is usually found much later.Import, read the fingerprint, trust the root, configure, commit1. IMPORTgpgsm --importyou.p12cert and key2. FINGERPRINTgpgsm--list-secret-keyscopy the 40 hex chars3. TRUST ROOT~/.gnupg/trustlist.txtskipped most often4. CONFIGUREgpg.format=x509user.signingkeygit config --global5. SIGNgit commit-Sor set gpgsignStep three does not affect signing. It affects believing yourself.Without the root in trustlist.txt your commits still sign and still verify on GitHub, whilegit log --show-signature on your own machine reports the certificate as untrusted.
    Four of these five steps are one command each. The third is a single line pasted into a file, and it is the one that makes local verification honest.

    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-keys

    The 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 relax

    Then 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 true

    The 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 0ff455a2708394633e4bb2f88002e3cd80cbd76f

    Using 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..HEAD

    That 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.

    Why GitHub and your local Git disagree about a commit signed with an expired certificateA timeline with four marked points on a single horizontal axis. Point one: the certificate is issued. Point two, highlighted in gold: the commit is signed and pushed, and at that moment the platform verifies the signature and writes a permanent verification record beside the commit, carrying a verified at timestamp. Point three: the certificate expires or is revoked. Point four: someone looks at the commit a year later. Below the axis, two outcome boxes show the disagreement. The platform box reads Verified, because it replays the stored record rather than re-checking the certificate. The local box reads certificate expired, because git verify-commit hands the signature to gpgsm, which evaluates the certificate fresh every time. A footer explains that both answers are correct because they answer different questions: was this valid when it was made, versus is this certificate valid right now.The badge freezes. Local verification does not.issuedcertificatesigned and pushedverification record writtenverified_at timestamp, not editableexpires or revokedsomeone looksOn GitHub: still VerifiedThe stored record is replayed. The certificateis never re-checked after that first push.Locally: certificate expiredgit verify-commit asks gpgsm, which judgesthe certificate against today.Neither tool is wrong. One asks whether the signature was valid when it was made; the other askswhether the certificate is valid now. Decide which question your policy actually cares about.
    This is the split that makes people think rotation broke their history. It did not; only the local answer moved.

    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.

    Still Have Questions?

    Contact our support team with questions about certificates, installation, or technical issues.

    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.