A git SSH server in 300 lines, with the key database in another service
How Rezee serves git over SSH with delegated public-key auth - denji holds no keys, resolves fingerprints through makima, and runs stateful upload-pack (no --stateless-rpc) - plus the measured ref-advertisement cost that separates SSH from HTTP transport.
Jun 25, 2026 · 7 min read · Kash Gohil
Rezee is one workspace for the whole product development lifecycle - code, planning, CI/CD, chat, and docs. You can clone and push over both HTTPS and SSH, and we've written about the smart-HTTP side. This post is the SSH side: a public-key git server in about 300 lines of Go (services/denji/internal/sshd/server.go) whose defining trait is that it holds no keys. The database of who's allowed lives in a different service entirely, and denji asks. That one decision, plus a subtle protocol difference from HTTP, is the whole post.
How do you authenticate SSH keys you don't store?
git-over-SSH is public-key auth: a client proves it holds the private key for a public key the server trusts. The catch is that "the server trusts" normally means "the server has a list of authorized keys" - ~/.ssh/authorized_keys, or a database denji owns. But denji is git-only by design: identity, users, and keys live in makima. So denji authenticates a key it has never seen by asking makima who owns it:
func (s *Server) publicKeyCallback(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
fp := ssh.FingerprintSHA256(key)
userID, err := s.sshAuth(fp) // ask makima: whose key is this?
if err != nil {
return nil, fmt.Errorf("unknown key")
}
return &ssh.Permissions{Extensions: map[string]string{"user-id": userID}}, nil
}The client offers a key; denji computes its SHA256 fingerprint and posts it to makima's /internal/ssh-auth, which looks the fingerprint up in the ssh_keys table and returns the owning user's id - or an error, which becomes "unknown key" and the handshake fails. denji stores nothing. The security property that makes this safe is fundamental to public-key crypto: a fingerprint is safe to send over the wire because possessing it proves nothing - the client still has to complete the SSH handshake by proving it holds the private key, which happens in the SSH library, not in makima. makima answers "whose key is this fingerprint," not "let this person in."
Then the neat part: the resolved userID is stashed in the SSH connection's Permissions.Extensions. SSH carries it for the life of the connection, so the session handler gets the authenticated user for free without re-resolving. Authentication happens once, at the handshake; the identity rides the connection.
Authenticate once, authorize per command
A connected client issues exactly one kind of request - an exec carrying a git command - and denji authorizes each one against the repo it names:
subcommand, repoName, err := parseGitCommand(command) // "git-upload-pack '/acme/web.git'"
// ...
op := "read"
if subcommand == "receive-pack" {
op = "write"
}
if err := s.authorize(repoName, op, userID); err != nil {
fmt.Fprintf(ch.Stderr(), "error: %v\n", err)
req.Reply(false, nil)
return
}upload-pack (clone/fetch) needs read; receive-pack (push) needs write. And authorize is the same internal call the HTTP path makes and the same one that answers every permission question - denji resolved a fingerprint to a user, and now makima decides whether that user may read or write that repo. Two transports, one authorization brain. The SSH server doesn't reimplement permissions; it reuses the decision makima already makes for HTTP, which is why adding SSH didn't add an authorization surface.
The one line that isn't in the HTTP server
Here's the protocol subtlety, and it's a single flag:
// SSH git transport is stateful — no --stateless-rpc flag.
cmd := exec.Command("git", subcommand, dir)
cmd.Stdin = ch
cmd.Stdout = chThe HTTP server runs git upload-pack --stateless-rpc; the SSH server runs plain git upload-pack. That flag is the whole difference between the two transports, and it exists because HTTP and SSH have opposite natures. HTTP is stateless: each request is independent, so --stateless-rpc tells git "you get one request-response, hold no state between rounds." SSH is a persistent bidirectional connection: git can run its normal stateful protocol, negotiating back and forth over the same pipe, because the pipe stays open.
That difference has a measurable cost, and it lands on the ref advertisement - the list of every branch and tag a repo has, which git sends before any transfer so the client knows what's available. We measured how big it gets (scripts/bench/ssh-transport):
About 75 bytes per ref, so a repo with 1,000 branches has a 73KB advertisement. Over stateful SSH, git sends it once per connection and then negotiates freely. Over stateless HTTP, every info/refs request re-sends the entire advertisement before negotiation can even start - so a multi-round incremental fetch on that 1,000-branch repo re-transmits ~734KB of advertisement across ten rounds where SSH transmits 73KB across one. For a busy monorepo with thousands of refs, the stateless re-advertisement is real overhead that the stateful transport simply doesn't pay.
One honest caveat: git's protocol v2 largely fixes this for HTTP by replacing the eager full advertisement with an on-demand ls-refs. denji currently runs the v0 protocol (v2 passthrough is deferred), so today the stateful SSH advantage on many-ref repos is real for denji specifically - and "add protocol v2" is on the list precisely because it closes this gap on the HTTP side.
What about the host key?
An SSH server needs a host key - the identity clients pin to detect man-in-the-middle. denji generates an Ed25519 host key on first boot and persists it, so the server has a stable identity without any provisioning step:
The choice of Ed25519 over RSA is the modern default: smaller keys, faster signatures, no key-size footguns. And generating-on-first-run rather than requiring an operator to supply one means docker compose up yields a working SSH server with a stable host identity, no key ceremony - the same "runs with one command" property the whole stack is built for. The tradeoff is that a fresh volume means a fresh host key, so clients see the host-key-changed warning after a full data reset; for a managed service where denji's storage is persistent, that's a non-issue.
FAQ
Isn't sending a key fingerprint to another service a security risk?
No, and it's worth being precise about why. A public-key fingerprint is derived from the public key, which is public by definition - knowing a fingerprint lets you identify a key, not impersonate it. The actual authentication (proving possession of the private key) happens in the SSH handshake inside denji's process, using Go's x/crypto/ssh, and makima never sees anything secret. makima answers a lookup - "which user registered this fingerprint" - exactly as safe to ask over an internal channel as "which user owns this repo."
Why is authentication delegated but authorization also delegated - isn't that two round-trips?
It's two questions because they're two different questions, and both have to be makima's because makima owns the data for both. Authentication (fingerprint → user) happens once per connection at the handshake. Authorization (may this user read/write this repo) happens per git command, because one connection could in principle name different repos. In practice a clone is one of each, and both are small internal JSON calls on denji's private network - cheap relative to transferring a packfile.
Why support SSH at all if HTTPS works?
Because SSH key auth is what a lot of developers and virtually all CI systems expect for git, and it avoids putting a token in a URL or a credential helper. The cost of supporting it turned out to be ~300 lines and zero new authorization logic - denji's SSH server is almost entirely transport plumbing on top of the identity and permission decisions makima already makes for HTTP. Reusing the authorization brain is what made a second transport cheap.