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.
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:
stepCLI 0.30.6 andstep-ca0.30.2 —brew install stepinstalls both on macOS. On Debian/Ubuntu, follow the installation page to add Smallstep's apt repo, thenapt-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 provided — step 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.crt → 2); 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 --daemonand an--exechook 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
- step-ca Getting Started — smallstep.com
- step ca init reference — smallstep.com
- step ca certificate reference — smallstep.com
- Install step-ca — smallstep.com
- Running step-ca in production — smallstep.com
- smallstep/certificates releases — github.com
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
No comments yet
Be the first to weigh in.