Skip to content
Security Advanced Tutorial

Enforce mTLS Between Services with a step-ca Private CA

Stand up step-ca, issue short-lived service certificates, and make Go services refuse any caller without one.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Aug 23, 2026 · 7 min read
Enforce mTLS Between Services with a step-ca Private CA

What you'll build

You'll stand up a private certificate authority with step-ca, issue short-lived certificates to two Go microservices (orders and billing), and configure billing to reject any caller that doesn't present a certificate signed by your CA. By the end, orders can call billing, and curl without a cert, a self-signed cert, or the wrong service identity all get refused at the TLS handshake.

Prerequisites

Verified on macOS 26 (arm64) and Debian 12 with:

  • step CLI 0.30.6 and step-ca 0.30.2 — brew install step installs both on macOS. On Debian/Ubuntu, follow the installation page to add Smallstep's apt repo, then apt-get install step-cli step-ca.
  • Go 1.25 or newer (go version).
  • curl (any recent version).

Everything runs on one machine on localhost. Ports 9000 (CA) and 8443 (billing) must be free. All commands below run from a single working directory; we keep the CA's state inside it via STEPPATH so nothing touches ~/.step.

1. Initialize the CA

step ca init generates a root CA, an intermediate CA that does the actual signing, and a JWK "provisioner" — the credential clients use to authenticate certificate requests. Run it non-interactively:

mkdir mtls-lab && cd mtls-lab
export STEPPATH="$PWD/stepdir"   # keep CA state local to this directory
mkdir -p "$STEPPATH"
echo -n "s3cret-ca-pass" > ca-password.txt

step ca init \
  --name="Internal CA" \
  --dns="localhost" \
  --address=":9000" \
  --provisioner="svc-admin" \
  --password-file=ca-password.txt \
  --deployment-type=standalone

Expected output (fingerprint will differ):

Generating root certificate... done!
Generating intermediate certificate... done!

✔ Root certificate: /.../stepdir/certs/root_ca.crt
✔ Root private key: /.../stepdir/secrets/root_ca_key
✔ Root fingerprint: ee8dc84b540b09be3b080aedc9502b224a08263ab1a869da1de834c36d19ea10
✔ Intermediate certificate: /.../stepdir/certs/intermediate_ca.crt
✔ Intermediate private key: /.../stepdir/secrets/intermediate_ca_key
✔ Database folder: /.../stepdir/db
✔ Default configuration: /.../stepdir/config/defaults.json
✔ Certificate Authority configuration: /.../stepdir/config/ca.json

Save that fingerprint — any other machine that needs to trust this CA bootstraps with step ca bootstrap --ca-url https://localhost:9000 --fingerprint <value>. The same password file encrypts both the CA keys and the provisioner key here; use separate files (--provisioner-password-file) outside a lab.

2. Start the CA

step-ca --password-file=ca-password.txt "$STEPPATH/config/ca.json" &

You should see Starting Smallstep CA/0.30.2 followed by Serving HTTPS on :9000 .... Confirm it's answering:

step ca health
ok

3. Issue a certificate to each service

Each service gets its own leaf cert with its own identity in the Subject/SAN. The provisioner password authorizes the request; in production you'd hand each service a scoped one-time token or use the ACME provisioner instead of sharing this password.

step ca certificate orders.internal  orders.crt  orders.key  \
  --provisioner svc-admin --provisioner-password-file ca-password.txt
step ca certificate billing.internal billing.crt billing.key \
  --provisioner svc-admin --provisioner-password-file ca-password.txt

Each command prints ✔ Certificate: orders.crt / ✔ Private Key: orders.key. Inspect one:

step certificate inspect orders.crt --short
X.509v3 TLS Certificate (ECDSA P-256) [Serial: 8207...5130]
  Subject:     orders.internal
  Issuer:      Internal CA Intermediate CA
  Provisioner: svc-admin [ID: RjyY...QLjk]
  Valid from:  2026-08-23T17:35:52Z
          to:  2026-08-24T17:36:52Z

Note the 24-hour lifetime — that's step-ca's default, and it's the point: short-lived certs make revocation mostly unnecessary. The .crt file contains the leaf plus the intermediate, so peers only need the root to verify.

4. Write the billing service (server side)

billing requires and verifies a client certificate against the CA root, then authorizes on the certificate's identity — only orders.internal may create invoices.

mkdir -p svc/billing svc/orders
cd svc && go mod init example.com/mtls-lab && cd ..
cp "$STEPPATH/certs/root_ca.crt" billing.crt billing.key svc/billing/
cp "$STEPPATH/certs/root_ca.crt" orders.crt  orders.key  svc/orders/

svc/billing/main.go:

package main

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"log"
	"net/http"
	"os"
)

func main() {
	rootPEM, err := os.ReadFile("root_ca.crt")
	if err != nil {
		log.Fatal(err)
	}
	pool := x509.NewCertPool()
	pool.AppendCertsFromPEM(rootPEM)

	cert, err := tls.LoadX509KeyPair("billing.crt", "billing.key")
	if err != nil {
		log.Fatal(err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/invoice", func(w http.ResponseWriter, r *http.Request) {
		peer := r.TLS.PeerCertificates[0] // non-empty: the handshake already required it
		if peer.Subject.CommonName != "orders.internal" {
			http.Error(w, "forbidden: "+peer.Subject.CommonName, http.StatusForbidden)
			return
		}
		fmt.Fprintf(w, "invoice created for %s\n", peer.Subject.CommonName)
	})

	srv := &http.Server{
		Addr:    ":8443",
		Handler: mux,
		TLSConfig: &tls.Config{
			Certificates: []tls.Certificate{cert},
			ClientAuth:   tls.RequireAndVerifyClientCert, // this is what makes it *mutual*
			ClientCAs:    pool,
			MinVersion:   tls.VersionTLS13,
		},
	}
	log.Println("billing listening on :8443 (mTLS required)")
	log.Fatal(srv.ListenAndServeTLS("", "")) // empty paths: certs come from TLSConfig
}

Start it:

(cd svc/billing && go run .) &

5. Write the orders service (client side)

orders presents its cert and verifies billing's cert against the same root. ServerName pins the expected identity, since we're dialing localhost but the cert says billing.internal.

svc/orders/main.go:

package main

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
)

func main() {
	rootPEM, err := os.ReadFile("root_ca.crt")
	if err != nil {
		log.Fatal(err)
	}
	pool := x509.NewCertPool()
	pool.AppendCertsFromPEM(rootPEM)

	cert, err := tls.LoadX509KeyPair("orders.crt", "orders.key")
	if err != nil {
		log.Fatal(err)
	}

	client := &http.Client{Transport: &http.Transport{
		TLSClientConfig: &tls.Config{
			Certificates: []tls.Certificate{cert},
			RootCAs:      pool,
			ServerName:   "billing.internal",
			MinVersion:   tls.VersionTLS13,
		},
	}}

	resp, err := client.Post("https://localhost:8443/invoice", "application/json", nil)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	fmt.Printf("%d %s", resp.StatusCode, body)
}

Verify it works

Run the client:

(cd svc/orders && go run .)
200 invoice created for orders.internal

Now prove the enforcement by attacking it with curl. --resolve maps billing.internal to loopback so hostname verification passes.

No client certificate — handshake fails:

curl -sS --cacert "$STEPPATH/certs/root_ca.crt" \
  --resolve billing.internal:8443:127.0.0.1 https://billing.internal:8443/invoice

The billing log shows http: TLS handshake error from 127.0.0.1:...: tls: client didn't provide a certificate.

A certificate from some other CA — also refused at handshake:

step certificate create rogue.internal rogue.crt rogue.key \
  --profile self-signed --subtle --no-password --insecure
curl -sS --cacert "$STEPPATH/certs/root_ca.crt" --cert rogue.crt --key rogue.key \
  --resolve billing.internal:8443:127.0.0.1 https://billing.internal:8443/invoice
curl: (56) ... SSL routines:ST_OK:tlsv1 alert unknown ca, errno 0

Billing logs tls: failed to verify certificate: x509: certificate signed by unknown authority.

A valid cert for the wrong service — handshake succeeds, authorization doesn't:

curl -sS --cacert "$STEPPATH/certs/root_ca.crt" --cert billing.crt --key billing.key \
  --resolve billing.internal:8443:127.0.0.1 -X POST https://billing.internal:8443/invoice
forbidden: billing.internal

And the legitimate caller via curl, for completeness:

curl -sS --cacert "$STEPPATH/certs/root_ca.crt" --cert orders.crt --key orders.key \
  --resolve billing.internal:8443:127.0.0.1 -X POST https://billing.internal:8443/invoice
invoice created for orders.internal

Finally, renew a cert before its 24 hours run out (in production, run step ca renew --daemon as a sidecar and reload on change):

step ca renew --force orders.crt orders.key
Your certificate has been saved in orders.crt.

Troubleshooting

'step ca renew' requires the '--ca-url' flag or flag '--ca-url' is required unless the '--token' flag is providedstep can't find defaults.json, almost always because STEPPATH isn't exported in this shell (new terminal tab, sudo, or a service unit). Re-run export STEPPATH="$PWD/stepdir", or pass --ca-url https://localhost:9000 --root "$STEPPATH/certs/root_ca.crt" explicitly.

failed to decrypt JWE: invalid password — the --provisioner-password-file contents don't match the password used at step ca init. Check for a trailing newline: the file must be written with echo -n, because step uses the bytes verbatim.

curl: (60) SSL certificate problem: unable to get local issuer certificate — the client rejected the server. You omitted --cacert (or RootCAs in Go), so the system trust store was used and your private root isn't in it. Pass the root explicitly; don't reach for -k/InsecureSkipVerify, which disables the server-side half of mTLS.

tls: client didn't provide a certificate in the server log while your Go client definitely loaded one — usually a ServerName mismatch earlier in the handshake, or the client cert file is a bare leaf without the intermediate. Confirm orders.crt contains two BEGIN CERTIFICATE blocks (grep -c BEGIN orders.crt2); certs from step ca certificate include the chain, hand-rolled ones often don't.

Next steps

  • Stop sharing the provisioner password: switch to the ACME provisioner so services enroll with an ACME client, or mint single-use tokens with step ca token.
  • Automate rotation with step ca renew --daemon and an --exec hook that reloads the service, per the renewal docs.
  • Replace CN-based authorization with SAN or URI checks (SPIFFE IDs fit naturally in URI SANs) and push the mTLS termination into Envoy or your ingress once you have more than a handful of services.
  • Move the root key offline: keep the root in a KMS or HSM and let only the intermediate sign, as the production guide describes.

Sources & further reading

  1. step-ca Getting Started — smallstep.com
  2. step ca init reference — smallstep.com
  3. step ca certificate reference — smallstep.com
  4. Install step-ca — smallstep.com
  5. Running step-ca in production — smallstep.com
  6. smallstep/certificates releases — github.com
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading