Commit signing for jj and Git with 1Password
Written by
At
noidilin
Tue May 12 2026

Commit signing used to sit in the “I know I should understand this better” corner of my Git knowledge.
I had seen the green Verified badge on GitHub. I knew GPG was involved historically. I also knew newer Git versions could sign with SSH keys. But the whole thing still felt a little magical: keys, signatures, agents, Git config, host verification, and then jj adding its own configuration layer on top.
What I wanted to understand was more concrete:
- What does a signed commit actually prove?
- What changes when the signature uses an SSH key instead of GPG?
- Where does 1Password fit into the signing flow?
- Why do Git and jj need separate configuration if jj is still writing Git commits?
What I learned is that commit signing is not as mysterious as it first looks. A signed commit is just a commit plus a cryptographic statement: “the person who made this signature had access to this private key.” The rest of the system is about deciding where that private key lives, how signing requests are approved, and which public keys are trusted by GitHub or by local verification tools.
My current mental model is:
commit payload -> signature -> public key -> account / allowed signerGit creates the commit payload. A signing backend produces a signature. A verifier checks that signature against a public key. A hosting service like GitHub can then connect that public key to an account and show the commit as verified.
The part I wanted to avoid was having raw private keys scattered across machines. That is why I use 1Password: the key lives in the vault, while Git and jj only talk to a signing helper.
What commit signing proves
A Git commit is already content-addressed. The commit hash comes from the commit object: the tree, parent commits, author, committer, message, and metadata. If the commit object changes, the hash changes.
That protects integrity, but not identity.
The author name and email inside a commit are just text fields. Anyone can write something like:
Author: Linus Torvalds <torvalds@linux-foundation.org>Git will store it. The hash will still be valid. Nothing about the hash proves that Linus created the commit.
Commit signing adds identity evidence. It attaches a cryptographic signature to the commit object. If the signature verifies against a trusted public key, then you know the matching private key was used to sign that exact commit payload.
That distinction helped me: signing does not mean “this code is safe” or “this author is morally trustworthy.” It means “this commit was signed by a key that your verifier trusts for this identity.”
SSH signing instead of GPG
Historically, Git commit signing usually meant GPG. That is powerful, but it also brings its own vocabulary and key-management model.
Git can also sign commits with SSH keys, which is nicer if SSH keys are already part of your development setup.
The slightly confusing bit is that Git’s configuration still uses the gpg namespace:
[gpg]
format = sshDespite the name, this tells Git to use SSH signatures rather than GPG signatures.
Then Git needs to know which public key identifies the signer and which program should perform the signing:
[user]
signingkey = ...
[gpg "ssh"]
program = "/Applications/1Password.app/Contents/MacOS/op-ssh-sign"The naming is awkward, but the model is simple: Git prepares data, calls a signing program, and stores the returned signature in the commit.
What 1Password changes
Without 1Password, the private key might live as a normal file under ~/.ssh. A passphrase helps, but the key is still a file on disk. You have to think about where it is stored, how it is unlocked, and which processes can read it.
1Password moves the private key into a vault item. The public key can still be copied into Git config, GitHub, or an allowed-signers file, but the private key does not need to be exported for Git to use it.
For commit signing, 1Password provides an SSH signing helper:
- macOS:
/Applications/1Password.app/Contents/MacOS/op-ssh-sign - Windows:
C:/Program Files/1Password/app/8/op-ssh-sign.exe - Linux: usually
op-ssh-sign
Git does not need to understand 1Password. It only needs to know which program to call when it needs a signature.
[gpg "ssh"]
program = "/Applications/1Password.app/Contents/MacOS/op-ssh-sign"When Git asks op-ssh-sign to sign a commit, 1Password checks whether the requested public key matches a private key in the vault. If it does, 1Password can ask for approval, unlock with Touch ID / Windows Hello / system authentication, and return the signature.
That was the key clarification for me: Git never needs the private key itself. Git only needs the signature that proves the private key was used.
The Git signing flow
With commit.gpgsign = true, the flow looks like this:
- I run
git commit. - Git builds the commit payload.
gpg.format = sshtells Git to create an SSH signature.user.signingkeytells Git which public key to sign as.- Git invokes the configured
gpg.ssh.program. - The 1Password signing helper receives the payload.
- 1Password finds the matching private key in the vault.
- I approve the signing request if approval is required.
- 1Password returns the signature.
- Git embeds the signature into the commit.
After that, the commit is pushed like any other commit. GitHub sees the signature, checks whether the public key is registered on my account, and shows the commit as verified if everything matches.
The useful trust chain is still:
commit payload -> SSH signature -> public key -> GitHub account / allowed signerWhat changes with jj?
jj changes the user experience around version control, but the signing concept is the same. A commit can be signed using a configured backend.
The part that tripped me up was assuming Git config would be enough. It is not.
When jj is backed by a Git repository, it still produces Git commits, so the resulting signatures can be verified by Git tooling and Git hosting services. But jj has its own configuration for deciding when and how to sign.
In Git, the shape is:
[user]
signingkey = ...
[gpg]
format = ssh
[gpg "ssh"]
program = ...
[commit]
gpgsign = trueIn jj, the same idea becomes:
[signing]
behavior = "own"
backend = "ssh"
key = "..."
backends.ssh.program = "..."The important fields are:
behavior = "own"signs commits I createbackend = "ssh"selects SSH signingkeyis the public signing keybackends.ssh.programpoints jj at the 1Password signing helper
So the separation is: Git needs Git config, jj needs jj config, and both can point at the same kind of 1Password signing helper.
My chezmoi setup
I manage this with chezmoi because the details differ across Windows, WSL, native Linux, and macOS.
The pattern is:
- use a different 1Password SSH key per machine or platform
- read the public key from 1Password during template generation
- point Git and jj at the platform-specific
op-ssh-signbinary - on WSL, reuse the Windows 1Password signing helper
Git config snippet
[user]
name = noidilin
email = linganinja.0120@gmail.com
{{- if eq .osId "windows" }}
signingkey = {{ onepasswordRead "op://dev/github-win/public key" | trim }} # 'github-win'
{{- else if eq .osId "linux-arch" }}
{{- if eq .archEnv "wsl" }}
signingkey = {{ onepasswordRead "op://dev/github-win/public key" | trim }} # WSL2 shares 'github-win' key
{{- else }}
signingkey = {{ onepasswordRead "op://dev/github-arch/public key" | trim }} # Native Arch 'github-arch'
{{- end }}
{{- else if eq .osId "darwin" }}
signingkey = {{ onepasswordRead "op://dev/github-mac/public key" | trim }} # 'github-mac'
{{- end }}
[core]
{{- if eq .osId "windows" }}
sshCommand = C:/Windows/System32/OpenSSH/ssh.exe
{{- else if eq .osId "linux-arch" }}
{{- if eq .archEnv "wsl" }}
sshCommand = ssh.exe
{{- end }}
{{- end }}
[gpg]
format = ssh
[gpg "ssh"]
{{- if eq .osId "windows" }}
program = "C:/Program Files/1Password/app/8/op-ssh-sign.exe"
{{- else if eq .osId "darwin" }}
program = "/Applications/1Password.app/Contents/MacOS/op-ssh-sign"
{{- else if eq .osId "linux-arch" }}
{{- if eq .archEnv "wsl" }}
program = "/mnt/c/Program Files/1Password/app/8/op-ssh-sign.exe"
{{- else if eq .archEnv "native" }}
program = "op-ssh-sign" # Native Arch uses Linux 1Password
{{- end }}
{{- end }}
[commit]
gpgsign = true
{{- if eq .chezmoi.os "windows" }}
[credential "helperselector"]
selected = manager
{{- end }}jj config snippet
[user]
"name" = "noidilin"
"email" = "linganinja.0120@gmail.com"
[signing]
"behavior" = "own"
"backend" = "ssh"
{{- if eq .osId "windows" }}
"key" = {{ onepasswordRead "op://dev/github-win/public key" | trim | quote }}
backends.ssh.program = "C:/Program Files/1Password/app/8/op-ssh-sign.exe"
{{- else if eq .osId "linux-arch" }}
{{- if eq .archEnv "wsl" }}
"key" = {{ onepasswordRead "op://dev/github-win/public key" | trim | quote }}
backends.ssh.program = "/mnt/c/Program Files/1Password/app/8/op-ssh-sign.exe"
{{- else }}
"key" = {{ onepasswordRead "op://dev/github-arch/public key" | trim | quote }}
backends.ssh.program = "op-ssh-sign"
{{- end }}
{{- else if eq .osId "darwin" }}
"key" = {{ onepasswordRead "op://dev/github-mac/public key" | trim | quote }}
backends.ssh.program = "/Applications/1Password.app/Contents/MacOS/op-ssh-sign"
{{- end }}What became clearer
The main mistake I had was treating commit signing as one big mysterious feature. It is easier to reason about as a few separate parts:
- Git or jj decides that a commit should be signed.
- SSH signing defines the signature format.
- 1Password protects and uses the private key.
- The public key is what verifiers and Git hosts need.
- Git and jj need separate config, even when jj is writing Git-compatible commits.
For my setup, this gives me signed commits across machines without keeping raw private keys loose on disk. It also gives me one chezmoi-generated configuration that adapts to Windows, WSL, Linux, and macOS.
The unresolved question I still want to explore is local verification policy: GitHub’s Verified badge is useful, but I also want a sharper mental model for allowedSignersFile, team-level trust, and how strict local verification should be in daily work.