Build an SSH Certificate Authority in Go
Build a zero-trust SSH gateway with your own CA that signs short-lived certs.
Introduction
30 Second Summary
Every server you SSH into today trusts a password or a permanent key. If either is stolen, the attacker has the same access you do, for as long as they want.
In this project, you will build a mini version of Teleport's core zero-trust security architecture in Go. You'll create a Certificate Authority that signs user keys into time-limited certificates, and an SSH gateway that only admits users who present one.
What You'll Build
When you're done, you'll issue a 2-hour SSH certificate for a user, connect to your gateway with it, and see a structured audit trail appear in the server logs.
By the end of this project, you'll have:
- An SSH gateway that rejects passwords entirely and only admits users holding a valid, CA-signed certificate.
- A Certificate Authority tool that takes any user's public key and produces a time-limited certificate that automatically stops working when it expires.
- Structured audit logs that record who connected, which certificate they used, and exactly when it expires.
- Secret Mission: Revoke a compromised certificate instantly, blocking access before it expires.
Are there any prerequisites?
This project assumes you're comfortable writing Go code. You should also have experience generating and using SSH keys.
Before We Start
Before you write any code, let's get clear on what you're building and why it matters.
Set Up Your Project
Before you can build an SSH server or sign certificates, you need the foundation: a Go project wired up with the right cryptographic library and a set of keys for each actor in the system.
In this step, you'll scaffold the Go module, pull in the golang.org/x/crypto/ssh library, and generate three ed25519 keypairs: one for the server, one for the Certificate Authority, and one for a test user.
In this step, get ready to:
- Create a Go module with the SSH cryptography dependency.
- Generate cryptographic keys for the server, CA, and a test user.
- Verify everything is wired up and ready to go.
Create the project directory and initialize the Go module
Every Go project starts with a module. The module file tracks your project's name and its dependencies.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows), type Terminal, and press Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir mini-teleport
cd mini-teleport
- Initialize a new Go module by running:
go mod init mini-teleport
You should see go: creating new go.mod: module mini-teleport confirming the file was created.
- Add the SSH cryptography library as a dependency by running:
go get golang.org/x/crypto/ssh
What does this command do?
go get downloads the golang.org/x/crypto module and adds it to your go.mod file.
The ssh package inside this module gives you native Go support for the SSH protocol, including certificate handling. This is the same library that Teleport itself uses under the hood.
Seeing a network error or timeout?
- Check your internet connection. go get downloads packages from the Go module proxy.
- Make sure Go is installed and on your PATH by running go version. You should see a version number.
- help me troubleshoot the go get error
Generate the SSH keys and project directories
Your gateway involves three distinct actors, and each one needs its own cryptographic identity: the server, the Certificate Authority, and a test user.
- Generate all three ed25519 keypairs by running these commands:
ssh-keygen -t ed25519 -f host_key -N "" -C "mini-teleport-host"
ssh-keygen -t ed25519 -f ca_key -N "" -C "mini-teleport-ca"
ssh-keygen -t ed25519 -f user_key -N "" -C "mini-teleport-user"
Each command prints a fingerprint and a randomart image. That confirms each keypair was generated successfully.
What are these three keys for?
- host_key is the server's identity. Clients use it to verify they're connecting to the right server.
- ca_key is the Certificate Authority's master key. It signs user keys into trusted certificates.
- user_key is a test user's personal key. You'll sign this into a certificate in Step 3.
Why ed25519 instead of RSA?
ed25519 is the modern default for SSH keys. It produces shorter keys with faster operations and stronger security than RSA.
- Create the two directories for your Go programs by running:
mkdir server ca
What are these directories for?
server/ will hold the SSH gateway server code. ca/ will hold the Certificate Authority tool that signs user keys into certificates.
Verify the setup
Before you move on to writing code, take a moment to confirm every piece is in place.
Before you run the next command, what do you expect to see listed in go.mod?
- Check your Go module file by running:
cat go.mod
You should see module mini-teleport at the top, a go version line, and a require line listing golang.org/x/crypto.
- Confirm all six key files and both directories exist by running:
ls host_key host_key.pub ca_key ca_key.pub user_key user_key.pub server/ ca/
You should see all six key files listed without errors, plus the server/ and ca/ directories (which appear empty for now).
Missing a file or directory?
- If a key file is missing, re-run the matching ssh-keygen command from the previous substep. Make sure you run it from inside the mini-teleport directory.
- If go.mod is missing, you may have run the commands from a different directory. Run pwd to check you're inside mini-teleport.
- help me figure out what's missing from my project setup
Your project skeleton is locked in. You have a Go module, the SSH library wired up, and three keypairs ready to go. Next up, you'll use these keys to build a working SSH server you can actually connect to.
Build a Password-Auth SSH Server
In the last step, you set up a Go module and generated three ed25519 keypairs. You have everything you need to start writing Go code.
Your goal for this step is to build a working SSH server that listens for connections and lets a user log in with a password. Starting with password authentication gives you a visible, connectable server you can test immediately.
In this step, get ready to:
- Create server/main.go with password-based SSH authentication.
- Run the server and connect to it from a second terminal.
- Confirm the welcome message, echo behavior, and connection log.
Create the SSH server entry point
Every SSH server needs two things: a host key that proves the server's identity, and an authentication method that decides who gets in. You will load the host key you generated in Step 1 and configure password authentication.
- Create a new file called main.go inside the server/ directory.
- Add the package declaration, imports, and the beginning of the main function by pasting this code:
package main
import (
"fmt"
"log"
"net"
"os"
"golang.org/x/crypto/ssh"
)
func main() {
// Load the server's host key for SSH handshakes
hostKeyBytes, err := os.ReadFile("host_key")
if err != nil {
log.Fatal("Failed to load host key: ", err)
}
hostKey, err := ssh.ParsePrivateKey(hostKeyBytes)
if err != nil {
log.Fatal("Failed to parse host key: ", err)
}
// Accept only username "testuser" with password "teleport"
config := &ssh.ServerConfig{
PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
if conn.User() == "testuser" && string(password) == "teleport" {
return &ssh.Permissions{}, nil
}
return nil, fmt.Errorf("invalid credentials")
},
}
config.AddHostKey(hostKey)
What does this code do?
This sets up two foundational pieces of your SSH server:
- os.ReadFile("host_key") loads the private key you generated with ssh-keygen. The server presents this key during the SSH handshake so clients can verify they are talking to the right server.
- PasswordCallback is a function the SSH library calls whenever a client tries to log in with a password. It checks for the username testuser and password teleport. Anything else gets rejected.
- Still inside the main function, add the TCP listener and connection accept loop right after config.AddHostKey(hostKey):
// Start listening for SSH connections on port 2222
listener, err := net.Listen("tcp", "0.0.0.0:2222")
if err != nil {
log.Fatal("Failed to listen on port 2222: ", err)
}
log.Println("Mini-Teleport gateway listening on port 2222...")
// Accept connections in an infinite loop
for {
conn, err := listener.Accept()
if err != nil {
log.Printf("Failed to accept connection: %s", err)
continue
}
go handleConnection(conn, config)
}
}
What does this code do?
This is the networking layer of your server:
- net.Listen("tcp", "0.0.0.0:2222") opens port 2222 on all network interfaces. Port 2222 is used instead of the standard SSH port 22 to avoid needing root privileges.
- The for loop runs forever, accepting each incoming TCP connection and spawning a goroutine (go handleConnection) to handle it concurrently.
Add the connection and session handlers
The main function hands each TCP connection off to handleConnection. That function needs to perform the SSH handshake and route incoming channels to a session handler.
- Below the closing brace of main(), add the handleConnection function:
func handleConnection(conn net.Conn, config *ssh.ServerConfig) {
// Perform the SSH handshake with the connecting client
sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
if err != nil {
log.Printf("Handshake failed: %s", err)
return
}
defer sshConn.Close()
log.Printf("New connection: user=%s remote=%s", sshConn.User(), sshConn.RemoteAddr())
// Discard global requests (keepalives, etc.)
go ssh.DiscardRequests(reqs)
// Accept only "session" channels
for newChannel := range chans {
if newChannel.ChannelType() != "session" {
newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
continue
}
channel, requests, err := newChannel.Accept()
if err != nil {
log.Printf("Could not accept channel: %s", err)
continue
}
go handleSession(channel, requests, sshConn.User())
}
}
What does this code do?
- ssh.NewServerConn upgrades the raw TCP connection into an authenticated SSH connection. It returns the connection metadata, a channel for incoming SSH channels, and a channel for global requests.
- ssh.DiscardRequests(reqs) discards global requests like keepalives. Without this, the connection would hang waiting for unhandled requests.
- The loop only accepts channels of type "session". SSH sessions carry the interactive shell. Everything else is rejected.
- Below handleConnection, add the first part of handleSession which sets up request routing and sends the welcome banner:
func handleSession(channel ssh.Channel, requests <-chan *ssh.Request, user string) {
defer channel.Close()
for req := range requests {
switch req.Type {
case "pty-req":
// Grant the pseudo-terminal allocation
if req.WantReply {
req.Reply(true, nil)
}
case "shell":
if req.WantReply {
req.Reply(true, nil)
}
// Send a welcome banner to the connected user
fmt.Fprintf(channel, "\r\nWelcome to Mini-Teleport, %s!\r\n", user)
fmt.Fprintf(channel, "You are authenticated via SSH certificate.\r\n")
fmt.Fprintf(channel, "Type 'exit' to disconnect.\r\n\r\n")
What's happening here?
When an SSH client connects and requests a shell, the server receives two request types in sequence:
- pty-req asks for a pseudo-terminal. The server grants it so the client can send keystrokes.
- shell asks for an interactive session. The server replies true, then sends three lines of welcome text to the user's terminal.
- Immediately after the welcome messages (still inside the "shell" case), add the echo loop and close out the function:
// Read user input and echo it back until they type exit
buf := make([]byte, 256)
for {
n, err := channel.Read(buf)
if err != nil {
return
}
input := string(buf[:n])
if input == "exit\r" || input == "exit\n" || input == "exit" {
fmt.Fprintf(channel, "Goodbye!\r\n")
return
}
channel.Write(buf[:n])
}
default:
if req.WantReply {
req.Reply(false, nil)
}
}
}
}
What does the echo loop do?
The echo loop is what makes the session interactive. It reads bytes from the SSH channel, checks if the user typed exit, and otherwise writes the same bytes back. This proves the connection is alive and bidirectional. The default case rejects any request type the server does not understand.
- Save server/main.go.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file, then move on to run the server.
ⓧ I'd like to double check the full code
Scroll back through the code you just added and confirm these chunks are present, in this order:
- Package declaration and imports (fmt, log, net, os, golang.org/x/crypto/ssh).
- main() function: host key loading, PasswordCallback config, TCP listener on port 2222, and accept loop calling go handleConnection(conn, config).
- handleConnection() function: ssh.NewServerConn handshake, ssh.DiscardRequests, session channel loop calling go handleSession(channel, requests, sshConn.User()).
- handleSession() function: pty-req and shell cases with welcome messages, echo loop reading input and checking for exit, and a default case rejecting unknown requests.
File not saving?
Make sure main.go is inside the server/ directory, not in the project root. The path should be server/main.go.
Help me find the right file path for my Go server code.
Run the server and connect
Your server code is ready. Time to see it in action.
- In your terminal, make sure you are in the mini-teleport project directory (not inside server/). Start the server by running:
go run ./server
You should see this line printed in the terminal:
Mini-Teleport gateway listening on port 2222...
Why is the terminal stuck?
The server is now running in the foreground, waiting for connections. It is not stuck. The terminal will stay busy until you stop the server with Ctrl+C. You need a second terminal to connect as a client.
- Open a second terminal window.
- Before you connect, predict what you expect to see. Will the SSH client show a password prompt, or will it connect automatically?
- Connect to your server by running:
ssh -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null testuser@localhost
What do these flags mean?
- -p 2222 tells the SSH client to connect to port 2222 instead of the default port 22.
- -o StrictHostKeyChecking=no skips the "are you sure you want to connect?" prompt. Your server's host key changes each time you regenerate it, so strict checking would block you.
- -o UserKnownHostsFile=/dev/null prevents your SSH client from saving the server's host key. This avoids stale-key warnings during development.
- When prompted for a password, type teleport and press Enter. The password will not appear on screen as you type.
You should see the welcome message:
Welcome to Mini-Teleport, testuser!
You are authenticated via SSH certificate.
Type 'exit' to disconnect.
- Type a few characters and watch them echo back.
- Type exit and press Enter to disconnect.
- Switch to the terminal where the server is running. You should see a log line like:
New connection: user=testuser remote=127.0.0.1:xxxxx
The exact port number after 127.0.0.1: will vary. What matters is that the server logged the username and the client's address.
Connection refused or password rejected?
- If you see Connection refused, make sure the server is still running in the first terminal. Check for error messages there.
- If the password is rejected, double check that you typed teleport exactly. The password is case-sensitive and invisible while typing.
- If you see a port-in-use error when starting the server, another process is using port 2222. Stop any previous server instances with Ctrl+C and try again.
Help me troubleshoot my SSH server connection.
You have a working SSH server that accepts password logins. Anyone who knows the password teleport can get in. In the next step, you will build a Certificate Authority that signs user keys into short-lived certificates, the first piece of replacing this password with something much stronger.
Build the Certificate Authority
Your SSH server is live and accepting connections. Anyone who knows the password can get in. That is exactly the problem.
Passwords can be guessed, phished, or brute-forced. The fix is a Certificate Authority (CA) that signs user keys into short-lived certificates. In this step, you'll build a CA tool that takes a user's public key and produces a time-limited, signed SSH certificate.
In this step, get ready to:
- Load cryptographic keys and create a CLI tool for certificate signing.
- Sign a user's public key into a short-lived SSH certificate.
- Inspect the signed certificate to verify its principal and validity window.
Create the CA entry point
The CA tool lives in its own Go program inside the ca/ directory. It accepts a sign subcommand with three arguments: a user's public key file, a principal (username), and how long the certificate should last.
- Create ca/main.go in your code editor and add the package declaration and imports:
package main
import (
"crypto/rand"
"fmt"
"log"
"os"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
What are these imports for?
- crypto/rand provides cryptographically secure randomness for signing certificates.
- golang.org/x/crypto/ssh contains the SSH certificate types, key parsers, and signing functions.
- strings and time handle the output filename and certificate validity windows.
- Add the main function below the imports. This is the CLI entry point that routes subcommands:
func main() {
// Print usage if no subcommand is given
if len(os.Args) < 2 {
fmt.Println("Mini-Teleport Certificate Authority")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" go run ./ca sign <user-pub-key> <principal> <duration>")
fmt.Println()
fmt.Println("Example:")
fmt.Println(" go run ./ca sign user_key.pub alice 2h")
os.Exit(1)
}
// Route the subcommand to its handler
switch os.Args[1] {
case "sign":
if len(os.Args) < 5 {
log.Fatal("Usage: go run ./ca sign <user-pub-key> <principal> <duration>")
}
signUserKey(os.Args[2], os.Args[3], os.Args[4])
default:
log.Fatalf("Unknown command: %s", os.Args[1])
}
}
What does this entry point do?
- If the user runs the tool with no arguments, it prints usage help and exits.
- The switch routes the sign subcommand to signUserKey, passing the public key file path, principal name, and duration.
- It validates that all three arguments are present before calling the handler.
- Save ca/main.go.
- Try building the CA package by running:
go build ./ca/
The compiler reports that signUserKey is undefined. That confirms the CLI routing is correct. The compiler is telling you exactly which function to build next.
Seeing a different error?
- If you see an import error, check that all import paths match the code above exactly. The external import golang.org/x/crypto/ssh must be inside the import block.
- If you see a syntax error, confirm you have the closing } for the switch statement and another for the main function.
Help me debug a Go compile error in my CA entry point
Add the certificate signing function
The signUserKey function is the heart of the CA. It loads the CA's private key, reads the user's public key, and produces a signed certificate.
- In ca/main.go, add the first half of signUserKey below the closing } of main. This section loads and parses the keys:
func signUserKey(pubKeyFile, principal, durationStr string) {
// Parse the requested certificate duration
duration, err := time.ParseDuration(durationStr)
if err != nil {
log.Fatalf("Invalid duration %q: %s", durationStr, err)
}
// Load and parse the CA's private signing key
caKeyBytes, err := os.ReadFile("ca_key")
if err != nil {
log.Fatal("Failed to load CA private key (ca_key): ", err)
}
caKey, err := ssh.ParsePrivateKey(caKeyBytes)
if err != nil {
log.Fatal("Failed to parse CA private key: ", err)
}
// Load and parse the user's public key
userPubKeyBytes, err := os.ReadFile(pubKeyFile)
if err != nil {
log.Fatalf("Failed to load user public key %s: %s", pubKeyFile, err)
}
userPubKey, _, _, _, err := ssh.ParseAuthorizedKey(userPubKeyBytes)
if err != nil {
log.Fatal("Failed to parse user public key: ", err)
}
What does this code do?
- time.ParseDuration converts a string like 2h into a Go duration value. This sets how long the certificate stays valid.
- ssh.ParsePrivateKey loads the CA's private key from ca_key. This is the key that will sign certificates.
- ssh.ParseAuthorizedKey reads the user's public key file. The five return values include the parsed key and metadata. You only need the key itself.
- Complete signUserKey by adding the certificate creation and output code directly below the key-loading section:
// Build the SSH certificate with a unique serial and time-bound validity
now := time.Now()
cert := &ssh.Certificate{
Key: userPubKey,
Serial: uint64(now.UnixNano()),
CertType: ssh.UserCert,
KeyId: fmt.Sprintf("%s@mini-teleport-%d", principal, now.Unix()),
ValidPrincipals: []string{principal},
ValidAfter: uint64(now.Unix()),
ValidBefore: uint64(now.Add(duration).Unix()),
}
// Sign the certificate with the CA's private key
if err := cert.SignCert(rand.Reader, caKey); err != nil {
log.Fatal("Failed to sign certificate: ", err)
}
// Write the signed certificate to disk
certFile := strings.TrimSuffix(pubKeyFile, ".pub") + "-cert.pub"
certBytes := ssh.MarshalAuthorizedKey(cert)
if err := os.WriteFile(certFile, certBytes, 0644); err != nil {
log.Fatal("Failed to write certificate: ", err)
}
// Print confirmation with certificate details
fmt.Println("Certificate signed successfully!")
fmt.Printf(" File: %s\n", certFile)
fmt.Printf(" Key ID: %s\n", cert.KeyId)
fmt.Printf(" Principal: %s\n", principal)
fmt.Printf(" Valid from: %s\n", now.Format(time.RFC3339))
fmt.Printf(" Valid until: %s\n", now.Add(duration).Format(time.RFC3339))
fmt.Printf(" Serial: %d\n", cert.Serial)
}
What do the certificate fields mean?
- CertType: ssh.UserCert marks this as a user certificate (as opposed to a host certificate).
- ValidPrincipals restricts which usernames the certificate is valid for. Only the named principal can use it.
- ValidAfter and ValidBefore create the time window. After the window expires, the certificate silently stops working.
- Serial is a unique identifier for the certificate, generated from the current nanosecond timestamp.
- cert.SignCert(rand.Reader, caKey) cryptographically signs the certificate with the CA's private key. The server can later verify this signature using only the CA's public key.
- Save ca/main.go.
✔️ Awesome, I've got everything!
Double check you've saved ca/main.go. The file should contain the import block at the top, the main function with the CLI routing, and the complete signUserKey function below it.
ⓧ I'd like to double check the full code
Scroll up and confirm your ca/main.go contains all four sections in order:
- The package main declaration and import block (7 imports including golang.org/x/crypto/ssh).
- The main() function with usage text, switch on os.Args[1], and the call to signUserKey.
- The key-loading section of signUserKey: duration parsing, CA key loading, and user public key loading.
- The certificate-creation section: the ssh.Certificate struct, cert.SignCert call, file write, and confirmation output.
Sign your first certificate
The CA tool is complete. Time to use it to sign the test user's public key into a short-lived certificate.
- Make sure you are in the mini-teleport project directory (where go.mod lives).
- Sign the test user's key by running:
go run ./ca sign user_key.pub alice 2h
What does this command do?
- go run ./ca compiles and runs the CA program.
- sign is the subcommand that routes to the signing function.
- user_key.pub is the public key to sign, alice is the principal (username), and 2h means the certificate expires in 2 hours.
You should see output confirming the certificate was signed, along with its file path, key ID, principal, validity window, and serial number.
- Confirm the certificate file was created by running:
ls user_key-cert.pub
You should see user_key-cert.pub listed. The CA tool automatically named it by stripping .pub from the input filename and appending -cert.pub.
Seeing an error instead of the certificate output?
- If you see "Failed to load CA private key", make sure you are running the command from the mini-teleport directory where ca_key lives.
- If you see "Failed to load user public key", confirm user_key.pub exists in the project root. Run ls user_key.pub to check.
- If you see a syntax or compile error, double check that all four code sections in ca/main.go are present and that the signUserKey function has a closing }.
Help me debug my CA signing tool
Inspect the certificate
The certificate file exists, but you need to verify its contents are correct. OpenSSH's ssh-keygen can decode and display the certificate's metadata.
Before you run the next command, what do you expect to see? Think about which principal, certificate type, and validity window your CA tool set.
- Inspect the certificate by running:
ssh-keygen -L -f user_key-cert.pub
The output shows the certificate type ssh-ed25519-cert-v01@openssh.com user certificate, the principal alice, and a validity window starting from now and ending 2 hours later.
Why does this matter?
This certificate is the replacement for a password. Instead of a static string anyone can type, you now have a cryptographically signed credential that names who it belongs to, which server principal they can use, and exactly when it stops working.
When the 2-hour window expires, the certificate becomes useless. No password resets, no key rotation. The expiry is built into the certificate itself.
Not seeing the expected output?
- If you see "is not a certificate", the file was not written correctly. Re-run the CA signing command and check for errors in the output.
- If the principal or validity looks wrong, check the arguments you passed to the sign command. The principal should be alice and the duration should be 2h.
Help me debug my SSH certificate inspection
You now have a working Certificate Authority that turns public keys into signed, time-limited credentials. Next up, you'll wire this into the server so it rejects passwords entirely and only admits users holding a valid certificate.
Switch to Certificate-Only Authentication
Your SSH server accepts anyone who types the password teleport. Passwords can be guessed, phished, or brute-forced.
In this step, you will replace password authentication with certificate-only authentication. Only users holding a valid, CA-signed certificate will be able to connect. This is the core of the zero-trust model that Teleport uses in production.
In this step, get ready to:
- Replace the PasswordCallback with an ssh.CertChecker that validates certificates against your CA.
- Verify that password connections are now rejected.
- Connect successfully using the certificate you signed in the previous step.
Load the CA public key and create a certificate checker
Your server currently trusts anyone with the right password. To trust certificates instead, it needs to know the CA's public key so it can verify that a certificate was actually signed by your authority.
- If your server is still running from Step 2, stop it by pressing Ctrl+C in the terminal where it is running.
- Open server/main.go in your editor.
- Find the PasswordCallback inside your ssh.ServerConfig and delete the entire PasswordCallback function, including the config initialization. You will replace it shortly.
- Find the line where hostKey is parsed. After that block, add the following code to load the CA public key:
// Load the CA public key to verify certificates
caPublicKeyBytes, err := os.ReadFile("ca_key.pub")
if err != nil {
log.Fatal("Failed to load CA public key: ", err)
}
caPublicKey, _, _, _, err := ssh.ParseAuthorizedKey(caPublicKeyBytes)
if err != nil {
log.Fatal("Failed to parse CA public key: ", err)
}
What does this code do?
This reads your CA's public key from ca_key.pub and parses it into an ssh.PublicKey value. The server will compare every incoming certificate's signing key against this value.
The ssh.ParseAuthorizedKey function returns five values. You only need the first one (the parsed key). The underscores discard the comment, options, remaining bytes, and trailing data.
- Directly below the CA key loading code, add the certificate checker and the new config:
// Create a certificate checker that trusts our CA
certChecker := &ssh.CertChecker{
IsUserAuthority: func(auth ssh.PublicKey) bool {
return string(auth.Marshal()) == string(caPublicKey.Marshal())
},
}
// Only accept certificate-based authentication
config := &ssh.ServerConfig{
PublicKeyCallback: certChecker.Authenticate,
}
config.AddHostKey(hostKey)
What does this code do?
The ssh.CertChecker handles all certificate validation: checking the signature, verifying the principal, and confirming the time window. You only need to tell it which CA to trust.
The IsUserAuthority callback compares the raw bytes of the certificate's signing key against your CA's public key. If they match, the certificate was signed by your CA.
Setting PublicKeyCallback to certChecker.Authenticate replaces the old PasswordCallback entirely. The server no longer accepts passwords at all.
- Save server/main.go.
- Confirm the code compiles by running:
go build ./server
If the build succeeds with no output, your code is syntactically correct.
Seeing a compile error?
- Make sure you removed the entire old PasswordCallback function from the config. Leaving part of it behind causes syntax errors.
- Check that caPublicKey is defined before the certChecker block. Go requires variables to be declared before use.
- If you see an unused import error, make sure you did not accidentally remove "os" from your imports.
Still stuck? Help me fix this compile error in server/main.go after switching from PasswordCallback to CertChecker.
✔️ Awesome, I've got everything!
Great. Double check you have saved server/main.go.
ⓧ I'd like to double check the full code
Your server/main.go is longer than 30 lines. Scroll up and confirm these sections are present in this order:
- Imports: fmt, log, net, os, and golang.org/x/crypto/ssh.
- Host key loading (os.ReadFile("host_key") and ssh.ParsePrivateKey).
- CA public key loading (os.ReadFile("ca_key.pub") and ssh.ParseAuthorizedKey).
- CertChecker with IsUserAuthority comparing marshalled keys.
- Config with PublicKeyCallback: certChecker.Authenticate (no PasswordCallback).
- Listener on 0.0.0.0:2222 with accept loop calling handleConnection.
- The handleConnection and handleSession functions unchanged from Step 2.
Try the old password method
Time to see your security upgrade in action. You removed password authentication, so what happens if someone tries to connect the old way?
- Start the updated server by running:
go run ./server
You should see Mini-Teleport gateway listening on port 2222... in the terminal.
- Open a second terminal. Before you run the next command, think about what you expect to happen. You are about to try connecting with the same password method that worked in Step 2. Do you think it will succeed or fail?
- Attempt the old password connection by running:
ssh -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null testuser@localhost
The connection is rejected. You will see a message containing Permission denied. The server no longer offers password authentication, so there is nothing for the client to try.
Why did this fail?
In Step 2, the server had a PasswordCallback that accepted the password teleport. You just removed that callback entirely.
The server now only has PublicKeyCallback set, which means it only accepts public-key-based authentication. Certificates are a special form of public key authentication, so the ssh.CertChecker plugs in naturally.
Connect with the certificate
The password door is sealed. The only way in is through a valid, CA-signed certificate. You created exactly that in Step 3.
- Before you run the next command, predict the outcome. You are connecting as alice (the principal on your certificate) with your signed key. Will the server accept you?
- Connect using the certificate by running:
ssh -i user_key -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null alice@localhost
How does the client know to use the certificate?
When you pass -i user_key, the SSH client automatically looks for a file named user_key-cert.pub next to the private key. If it finds one, it presents the certificate to the server. No extra flags needed.
You should see this output:
Welcome to Mini-Teleport, alice!
You are authenticated via SSH certificate.
Type 'exit' to disconnect.
Your server verified that the certificate was signed by the CA, that the principal alice matches the username, and that the certificate has not expired. Only then did it grant access.
- Type exit in the SSH session to disconnect.
- Check the server terminal. You should see a log line like New connection: user=alice remote=... confirming the certificate-authenticated session.
Connection rejected even with the certificate?
- Make sure user_key-cert.pub exists in the same directory as user_key. Run ls user_key-cert.pub to confirm.
- Confirm you are connecting as alice (the principal on your certificate), not testuser. The server matches the username against ValidPrincipals on the certificate.
- If the certificate has expired (you signed it more than 2 hours ago), re-sign it by running go run ./ca sign user_key.pub alice 2h and try again.
Still stuck? Help me debug why my certificate-based SSH connection is being rejected by my Go server.
Your gateway now rejects passwords and only admits certificate holders. One thing is missing though. The server logs show that someone connected, but not which certificate they used. In the next step, you will add structured audit logging that captures the certificate's identity, serial number, and expiry for every connection.
Add Audit Logging
Your server now blocks anyone without a CA-signed certificate. But in a real zero-trust system, blocking bad connections is only half the story. You also need to trace every connection to a specific certificate, its serial number, and its expiry time.
This is how real infrastructure teams detect compromised certificates and investigate incidents. In this step, you will add structured audit logging so every session is traceable.
In this step, get ready to:
- Wrap certChecker.Authenticate in a custom callback that extracts certificate metadata.
- Add structured audit log lines that track sessions from start to finish.
- Test the full audit trail by connecting and disconnecting.
Extract certificate metadata in the auth callback
Right now, certChecker.Authenticate validates the certificate and returns permissions. But it does not preserve any certificate details for later use. You need to wrap it in a custom function that grabs the certificate's identity, serial number, and expiry before they disappear.
- In server/main.go, find the import block at the top of the file. Add "time" after "os" so the imports look like this:
import (
"fmt"
"log"
"net"
"os"
"time"
"golang.org/x/crypto/ssh"
)
- Next, scroll down to the config declaration and find these lines:
config := &ssh.ServerConfig{
PublicKeyCallback: certChecker.Authenticate,
}
- Replace that entire block with this custom callback closure:
config := &ssh.ServerConfig{
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
// Delegate validation to the cert checker
perms, err := certChecker.Authenticate(conn, key)
if err != nil {
log.Printf("[AUDIT] Auth failed: user=%s remote=%s error=%s",
conn.User(), conn.RemoteAddr(), err)
return nil, err
}
// Extract certificate metadata into permissions
if cert, ok := key.(*ssh.Certificate); ok {
if perms.Extensions == nil {
perms.Extensions = make(map[string]string)
}
perms.Extensions["cert-id"] = cert.KeyId
perms.Extensions["cert-serial"] = fmt.Sprintf("%d", cert.Serial)
perms.Extensions["cert-expires"] = time.Unix(int64(cert.ValidBefore), 0).Format(time.RFC3339)
}
return perms, nil
},
}
What does this code do?
Instead of passing certChecker.Authenticate directly, you wrap it in a closure that intercepts the result before returning it.
- On failure, the closure logs an [AUDIT] Auth failed line with the user and remote address before rejecting the connection.
- On success, it type-asserts the key to *ssh.Certificate and copies KeyId, Serial, and ValidBefore into perms.Extensions.
- The extensions map acts as a sidecar: it carries certificate metadata from the authentication phase into the connection handler where you can log it.
- Save server/main.go.
- Switch to the terminal running your server and press Ctrl+C to stop it.
- Restart the server by running:
go run ./server
- In your second terminal, connect with your certificate by running:
ssh -i user_key -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null alice@localhost
You should see the Welcome to Mini-Teleport, alice! message. The wrapper did not break certificate authentication.
- Type exit to disconnect, then stop the server with Ctrl+C.
Connection refused or compile error?
- Check that you added "time" to the import block. The time.Unix call in the closure needs it.
- Make sure the closing braces of the closure, the ServerConfig struct, and the function all match up. A missing comma after the closure's closing brace will cause a compile error.
- If your certificate has expired (more than 2 hours since you signed it), re-sign it by running go run ./ca sign user_key.pub alice 2h.
Why won't my server compile after adding the custom callback?
Log sessions from start to finish
The callback now stores certificate metadata in the permissions extensions map. The handleConnection function can read those extensions and write structured audit log lines whenever a session starts or ends.
- In server/main.go, scroll down to the handleConnection function. Find this line inside the error check:
log.Printf("Handshake failed: %s", err)
- Replace it with this audit-prefixed version that includes the remote address:
log.Printf("[AUDIT] Handshake failed: remote=%s error=%s", conn.RemoteAddr(), err)
- Now find these two lines just below defer sshConn.Close():
defer sshConn.Close()
log.Printf("New connection: user=%s remote=%s", sshConn.User(), sshConn.RemoteAddr())
- Replace the log.Printf("New connection...") line with the extension reads and a structured session-started log. The section should now look like this:
defer sshConn.Close()
// Read certificate metadata stored by the auth callback
certID := sshConn.Permissions.Extensions["cert-id"]
certSerial := sshConn.Permissions.Extensions["cert-serial"]
certExpires := sshConn.Permissions.Extensions["cert-expires"]
log.Printf("[AUDIT] Session started: user=%s remote=%s cert_id=%s serial=%s expires=%s",
sshConn.User(), sshConn.RemoteAddr(), certID, certSerial, certExpires)
What does this code do?
- The three sshConn.Permissions.Extensions reads pull out the metadata your custom callback stored during authentication.
- The [AUDIT] Session started log creates a structured record with the username, remote address, certificate key ID, serial number, and expiry time.
- This is the same kind of structured log line that production systems like Teleport use to build audit trails and investigate security incidents.
- Finally, scroll to the very end of the handleConnection function. Add this log line just before its closing }, after the for newChannel := range chans loop ends:
log.Printf("[AUDIT] Session ended: user=%s remote=%s cert_id=%s",
sshConn.User(), sshConn.RemoteAddr(), certID)
Why log at the end of the function?
The for newChannel := range chans loop blocks until the client disconnects and the channel stream closes. Code after the loop only runs once the session is truly over, making it the perfect place for the session-ended log.
- Save server/main.go.
✔️ Awesome, I've got everything!
Great. Make sure you have saved server/main.go before moving on.
ⓧ I'd like to double check the full code
The full server/main.go is long, so scroll up and confirm each piece is in place, in this order:
- The import block includes "time" after "os".
- The PublicKeyCallback is the full custom closure (not the old certChecker.Authenticate one-liner).
- In handleConnection, the handshake error log says [AUDIT] Handshake failed.
- After defer sshConn.Close(), the three extension reads (certID, certSerial, certExpires) and the [AUDIT] Session started log are present.
- At the bottom of handleConnection, after the channel loop, the [AUDIT] Session ended log is present.
Test the full audit trail
Time to see the audit trail in action. You will restart the server, connect with your certificate, disconnect, and check the server terminal for the structured log lines.
- Restart the server by running:
go run ./server
Before you connect, think about this: what information do you expect to see in the server terminal when you connect? What about when you disconnect?
- In your second terminal, connect with your certificate by running:
ssh -i user_key -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null alice@localhost
- Check the server terminal. You should see a log line like this:
[AUDIT] Session started: user=alice remote=127.0.0.1:... cert_id=alice@mini-teleport-... serial=... expires=...
The log line traces this connection to the exact certificate that granted access, including when that certificate expires.
- Back in the SSH session, type exit to disconnect.
- Check the server terminal again. You should now see a matching session-ended line:
[AUDIT] Session ended: user=alice remote=127.0.0.1:... cert_id=alice@mini-teleport-...
Every connection is now traceable. You can see who connected, which certificate they used, and when that certificate expires.
Not seeing the audit log lines?
- Make sure you stopped the old server with Ctrl+C before restarting. If the old server is still running on port 2222, your client connects to the old code.
- If you see Session started but not Session ended, make sure you typed exit in the SSH session instead of closing the terminal window. Closing the window may not cleanly trigger the channel close.
- Confirm the [AUDIT] Session ended log line is placed after the for newChannel := range chans loop, not inside it.
Why aren't my audit log lines appearing in the server terminal?
Secret mission
Add Certificate Revocation
Your gateway trusts any valid certificate signed by the CA. But what happens if a certificate is compromised before it expires? Right now, there is no way to revoke it. Add a file-based revocation list that lets you instantly block compromised certificates, the same pattern real zero-trust systems use.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine with no cloud resources or paid services. There are no ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The mini-teleport/ project directory containing Go source files, SSH keys, certificates, and the revocation list.
Keep everything running
No action needed. Choose this if you want to keep experimenting with certificate signing, revocation, or extending the server.
- Your project files stay in ~/Desktop/mini-teleport/. You can return anytime and run the server again.
- The SSH keys and certificates in this directory are only valid for local testing. They pose no security risk on your machine.
Pause - I'll come back to this later
Stop the running server process to free up port 2222, but keep all project files for later.
- If the server is still running in a terminal, press Ctrl+C to stop it.
- Your project files remain in ~/Desktop/mini-teleport/. When you come back, run go run ./server from that directory to start the gateway again.
- Your existing certificates will expire after their 2-hour validity window. Issue a fresh one with the CA tool when you return.
Delete - I don't want to use this again
Remove all project files and start fresh.
- If the server is still running in a terminal, press Ctrl+C to stop it.
- Delete the entire project directory by running:
rm -rf ~/Desktop/mini-teleport
- Confirm the directory is gone by running:
ls ~/Desktop/mini-teleport
You should see a "No such file or directory" message confirming the deletion.
That's a Wrap!
That's a Wrap!
Nice work! You've just built a zero-trust SSH gateway powered by your own Certificate Authority in Go. Every connection to your server is authenticated by a short-lived certificate, verified against your CA, and traced in a structured audit log.
You've learned how to:
- Build a Go SSH server and Certificate Authority from scratch using golang.org/x/crypto/ssh, handling connections, channels, and certificate signing with principal restrictions and expiry.
- Replace password authentication with certificate-only authentication using ssh.CertChecker, the same pattern Teleport uses at its core.
- Add structured audit logging that traces every connection to a specific certificate identity, serial number, and expiry.
- Secret Mission: Implement certificate revocation to instantly block compromised certificates without waiting for expiry.
Ready to quiz yourself?