Tutorials
Tutorial: mutual TLS end to end
Build a private client CA with openssl, issue server and client certificates, map certificates to names and scopes with the identity file, call the server with curl, and revoke a client with a CRL.
This walkthrough takes Secronyx from no PKI at all to a listener that refuses every connection without a trusted client certificate, maps each certificate to an operator name and a scope list, and revokes a certificate without a restart. The outputs shown are trimmed: server log lines appear without the date and time prefix the Go standard logger adds, and audit records without their timestamp, event_id, prev_hash and hash fields. The reference material behind it is on Authentication, Scopes and authorization and the Configuration reference.
You need openssl 3.x, curl, jq and the secronyx binary. The lab runs on loopback so nothing leaves the host; the last section covers what changes for a real deployment.
How the pieces fit
Two layers make a decision. The TLS listener, configured with --tls-client-ca, requires a certificate that chains to one of the CAs in that bundle before it reads a single HTTP byte; a connection without one fails in the handshake. The mTLS authenticator then turns the verified certificate into an identity: it checks the extended key usage, an optional SPIFFE trust domain, an optional CRL, and finally looks the certificate up in the identity file (--mtls-identity-file) to find a name and scopes. With --mtls-require-mapping, a certificate that has no entry is denied even though the handshake accepted it.
1. Create the client CA
Work in a scratch directory with a restrictive umask so private keys are never world-readable.
mkdir -p ~/mtls-lab && cd ~/mtls-lab && umask 077
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out ca-key.pem
cat > ca.cnf <<'EOF'
[req]
distinguished_name = dn
x509_extensions = ext
prompt = no
[dn]
CN = Example Ops Client CA
[ext]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign, digitalSignature
subjectKeyIdentifier = hash
EOF
openssl req -new -x509 -key ca-key.pem -out ca.pem -days 3650 -config ca.cnfca.pem is what the server trusts (--tls-client-ca) and what clients use to verify the server if the same CA also signs the server certificate, as it does here. ca-key.pem signs client certificates and CRLs; in production it lives in your PKI, not next to the server.
2. Issue the server certificate
The listener needs an ordinary TLS certificate (--tls-cert/--tls-key); mutual TLS is refused without one (a client CA bundle (mutual TLS) requires --tls-cert and --tls-key). The subject alternative names must cover whatever clients put in the URL.
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out server-key.pem
cat > server.cnf <<'EOF'
[req]
distinguished_name = dn
prompt = no
[dn]
CN = mcp.example.internal
[ext]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = DNS:mcp.example.internal,DNS:localhost,IP:127.0.0.1
EOF
openssl req -new -key server-key.pem -out server.csr -config server.cnf
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out server.pem -days 397 -extfile server.cnf -extensions ext3. Issue two client certificates
The authenticator requires the clientAuth extended key usage when any EKU is present, and Go's TLS stack rejects a certificate whose EKU excludes client authentication before the request is even read (x509: certificate specifies an incompatible key usage). Give every client certificate extendedKeyUsage = clientAuth.
Alice is an SRE who will get an explicit mapping; Bob is a contractor who will not.
for who in alice bob; do
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out $who-key.pem
done
cat > alice.cnf <<'EOF'
[req]
distinguished_name = dn
prompt = no
[dn]
CN = alice
OU = SRE
[ext]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectAltName = DNS:alice.ops.example.internal
EOF
sed -e 's/CN = alice/CN = bob/' -e 's/OU = SRE/OU = Contractors/' \
-e 's/DNS:alice/DNS:bob/' alice.cnf > bob.cnf
for who in alice bob; do
openssl req -new -key $who-key.pem -out $who.csr -config $who.cnf
openssl x509 -req -in $who.csr -CA ca.pem -CAkey ca-key.pem -CAserial ca.srl \
-out $who.pem -days 90 -extfile $who.cnf -extensions ext
done
openssl x509 -in alice.pem -noout -subject -serial -ext subjectAltName,extendedKeyUsagesubject=CN = alice, OU = SRE
serial=5478767BDC866DFE4E8CC69E030282A885DC4C5E
X509v3 Extended Key Usage:
TLS Web Client Authentication
X509v3 Subject Alternative Name:
DNS:alice.ops.example.internal
Keep the serial; revocation in step 9 refers to it.
4. Write the identity file
The most robust way to pin an identity is the SHA-256 of the certificate's SubjectPublicKeyInfo. It survives renewal with the same key, and the server prefers it over every other match type, so a stranger's certificate with a familiar CN cannot borrow a pinned identity's scopes.
SPKI=$(openssl x509 -in alice.pem -pubkey -noout \
| openssl pkey -pubin -outform DER | openssl dgst -sha256 | awk '{print $NF}')
echo "$SPKI"5cc491bed963a020b7276d527f2953582bec54a057e3ab6fc7ca2b1dbc7e7774
cat > identities.json <<EOF
{
"version": 1,
"identities": [
{ "match": { "spki_sha256": "$SPKI" }, "name": "alice", "scopes": ["core", "logs"] },
{ "match": { "ou": "SRE" }, "name": "sre-team", "scopes": ["core"] }
]
}
EOF
chmod 644 ca.pem server.pem alice.pem bob.pem identities.jsonRules the loader enforces at startup, each of which is fatal: version must be 1; every entry needs a name; each match must contain exactly one of spki_sha256, spiffe_id, dns, cn or ou; and "*" is not accepted as a scope. Matching precedence across the whole file is spki_sha256, then spiffe_id, then dns, then cn, then ou. spki_sha256 and dns and spiffe_id match case-insensitively (the fingerprint may carry a sha256: prefix and colons); cn and ou match exactly. An entry with "disabled": true denies its certificate outright.
5. Create an empty CRL
--mtls-crl must point at a parseable CRL when the server starts (mtls CRL: stat ./nope.crl: no such file or directory otherwise), so issue an empty one now. openssl ca needs a small database.
: > index.txt && echo 01 > crlnumber
cat > crl.cnf <<'EOF'
[ca]
default_ca = ops
[ops]
database = index.txt
crlnumber = crlnumber
default_md = sha256
default_crl_days = 30
certificate = ca.pem
private_key = ca-key.pem
EOF
openssl ca -config crl.cnf -gencrl -out ca.crl
chmod 644 ca.crl
openssl crl -in ca.crl -noout -issuer -nextupdateissuer=CN = Example Ops Client CA
nextUpdate=Oct 17 11:17:05 2026 GMT
Note nextUpdate. The server treats an expired CRL as a failed revocation check, which denies every client whose certificate was issued by that CRL's issuer rather than silently passing them (step 9).
6. Start the server
secronyx --transport http --listen 127.0.0.1:8443 \
--tls-cert server.pem --tls-key server-key.pem \
--tls-client-ca ca.pem \
--mtls-identity-file identities.json --mtls-require-mapping \
--mtls-crl ca.crl \
--audit-output ./audit.jsonlAudit logging enabled: provider=default output=./audit.jsonl
Scope policy: 9 tools not registered (sensitive=false, scopes="")
MCP HTTP Server starting on 127.0.0.1:8443
Server URL: https://127.0.0.1:8443
Auth: mtls, mtls
Client CA: ca.pem (client certificate required)
mtls appears twice: once because the listener requires a client certificate, once for the identity authenticator. --mtls-identity-file without --tls-client-ca is refused at startup (--mtls-identity-file requires --tls-client-ca), as is a malformed identity file, for example mtls identity file: identities.json: identities[0] (alice) must have exactly one match key.
7. Call it as Alice
curl presents the client certificate with --cert and --key and verifies the server with --cacert.
CURL="curl -s --cacert ca.pem --cert alice.pem --key alice-key.pem"
$CURL https://127.0.0.1:8443/health
# {"status":"ok"}
$CURL -X POST https://127.0.0.1:8443/ -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools | length'
# 13
$CURL -X POST https://127.0.0.1:8443/ -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_uptime","arguments":{}}}' | cut -c1-120
# {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\n \"boot_time\": \"2026-08-27T23:56:23.68Alice's mapping grants core and logs, so tools/list shows the 8 core and 5 logs tools and nothing else. Calling outside those scopes is refused at the JSON-RPC layer with code -32003:
$CURL -X POST https://127.0.0.1:8443/ -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_listening_ports","arguments":{}}}'
# {"jsonrpc":"2.0","id":3,"error":{"code":-32003,"message":"Forbidden","data":"tool \"get_listening_ports\" requires scope \"hooks\""}}The audit file records the authentication with the mapped name, the method and a client ID derived from the SPKI, followed by the tool call under the same identity:
{"seq":1,"action":"auth/token_validation","identity":"alice","client_ip":"127.0.0.1","result":"success","metadata":{"client_id":"mtls:5cc491bed963a020b7276d527f2953582bec54a057e3ab6fc7ca2b1dbc7e7774","method":"mtls","scopes":["core","logs"]}}
{"seq":2,"action":"tools/call","resource":"get_uptime","identity":"alice","client_ip":"127.0.0.1","result":"success","duration_ns":85002}8. Watch the failure modes
No certificate at all never reaches HTTP. curl reports the handshake alert and the server logs it:
curl --cacert ca.pem -X POST https://127.0.0.1:8443/ -d '{}'
# curl: (56) OpenSSL SSL_read: ... tlsv13 alert certificate required, errno 0http: TLS handshake error from 127.0.0.1:49724: tls: client didn't provide a certificate
Bob's certificate is valid, but under --mtls-require-mapping it has no entry. That is decided after the handshake, so it is an HTTP 401 with the same JSON body and WWW-Authenticate header every rejected credential gets:
curl -s -i --cacert ca.pem --cert bob.pem --key bob-key.pem -X POST https://127.0.0.1:8443/ \
-H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'HTTP/2 401
content-type: application/json
www-authenticate: Bearer realm="secronyx", resource_metadata="https://127.0.0.1:8443/.well-known/oauth-protected-resource"
{"error":"unauthorized","error_description":"mtls: client certificate \"bob.ops.example.internal\" has no identity mapping"}
The subject in that message is chosen in the order SPIFFE ID, first DNS SAN, CN, then spki:<fingerprint>. Without --mtls-require-mapping, an unmapped certificate gets --mtls-default-scopes if set (--mtls-default-scopes core would give Bob the 8 core tools under the identity bob.ops.example.internal), and is otherwise denied with has no identity mapping and no default scopes are configured.
Disabling an entry takes effect while the server runs. The identity file is re-read when its modification time changes, polled at most every 2 seconds:
jq '.identities[0].disabled = true' identities.json > i.tmp && mv i.tmp identities.json
sleep 3
$CURL -X POST https://127.0.0.1:8443/ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# {"error":"unauthorized","error_description":"mtls: identity \"alice\" is disabled"}
jq 'del(.identities[0].disabled)' identities.json > i.tmp && mv i.tmp identities.jsonA rewrite that fails validation keeps the last good mapping and is not logged, so confirm every edit with a request.
9. Revoke Alice with the CRL
Revocation is the CA's job. Add Alice's certificate to the database, regenerate the CRL in place, and the server picks it up on the same 2-second mtime poll.
openssl ca -config crl.cnf -revoke alice.pem
# Database updated
openssl ca -config crl.cnf -gencrl -out ca.crl
openssl crl -in ca.crl -noout -text | grep -A1 'Serial Number'
# Serial Number: 5478767BDC866DFE4E8CC69E030282A885DC4C5E
# Revocation Date: Sep 17 11:17:53 2026 GMT
sleep 3
$CURL -X POST https://127.0.0.1:8443/ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# {"error":"unauthorized","error_description":"mtls: client certificate serial 482241635570625431579738933463421795620519431262 is revoked"}The server prints the serial in decimal while openssl prints hex; python3 -c 'print(int("5478767BDC866DFE4E8CC69E030282A885DC4C5E", 16))' converts between them.
Three properties of the check matter operationally. A CRL is consulted only when its issuer equals the leaf's issuer, so a CRL from another CA says nothing and does not deny. When the issuing certificate is available in the verified chain, the CRL's signature is verified against it, and a bad signature is a denial (CRL signature does not verify against the issuer). And an expired CRL denies every client from that issuer until it is replaced:
{"error":"unauthorized","error_description":"mtls: revocation check: CRL expired at 2026-09-17T08:30:32Z"}
Schedule CRL regeneration well inside default_crl_days, and alert on the auth/token_validation denials that carry CRL expired.
10. Combine with another credential
The authenticator chain runs API keys first, SSH signatures second and mTLS last, so a request that carries an API key or an SSH signature on a client-certificate connection is identified by that credential and the certificate acts only as a transport gate. Bearer tokens, OIDC and introspection sit behind the whole chain, so a certificate that produces an identity answers before they are consulted. With --tls-client-cert-optional the handshake also admits connections with no certificate, which must then authenticate another way:
secronyx --transport http --listen 127.0.0.1:8443 \
--tls-cert server.pem --tls-key server-key.pem \
--tls-client-ca ca.pem --tls-client-cert-optional \
--mtls-identity-file identities.json --mtls-require-mapping \
--api-keys-file keys.json Auth: mtls, api-key, mtls
Client CA: ca.pem (client certificate optional)
A request with neither credential gets no acceptable credentials presented; one with an API key and no certificate is identified by the key; one with Bob's unmapped certificate plus a valid key is identified by the key, because the key authenticator answers first. To require both a certificate and a key, drop --tls-client-cert-optional, --mtls-identity-file and --mtls-default-scopes: the handshake demands the certificate, and only the key can produce an identity. See Tutorial: per-operator API keys for the key store and Tutorial: SSH key request signing for the middle link in that chain.
SPIFFE workloads
Workloads attested by SPIRE present X.509 SVIDs whose URI SAN is spiffe://<trust-domain>/<path>. Restrict the listener to a trust domain and match on the SPIFFE ID:
# client certificate with: subjectAltName = URI:spiffe://corp.example/ns/ops/sa/triage
cat > identities-spiffe.json <<'EOF'
{ "version": 1, "identities": [
{ "match": { "spiffe_id": "spiffe://corp.example/ns/ops/sa/triage" }, "name": "triage-bot", "scopes": ["core", "logs"] }
] }
EOF
secronyx --transport http --listen 127.0.0.1:8443 \
--tls-cert server.pem --tls-key server-key.pem --tls-client-ca ca.pem \
--mtls-identity-file identities-spiffe.json --mtls-trust-domain corp.exampleWith --mtls-trust-domain set, a certificate without a SPIFFE ID is denied (client certificate carries no SPIFFE ID; trust domain restriction is in force) and one from another domain with SPIFFE ID "..." is outside the allowed trust domains. The audit identity is the mapped name (triage-bot), and the client ID is still mtls:<spki>.
Production notes
- Point
--tls-client-caat your organisational CA or the SPIRE trust bundle. The bundle is read once when the listener starts; rotating its roots needs a restart. The identity file and the CRL are re-read on change. - Bind to
0.0.0.0:8443and set--server-url https://mcp.example.internal:8443so theWWW-Authenticatemetadata URL is correct. The listener negotiates TLS 1.2 or later with AEAD suites only. - Keep
--mtls-require-mappingon. Default scopes are a migration aid; a wildcard is never implied and cannot be written into the identity file. - Store
ca-key.pemin the CA, not on the server. The server needs onlyca.pem,ca.crl, its own certificate and key, andidentities.json. - For a fleet, distribute the same identity file to every host and treat it as configuration under change control; see Fleet deployment. The repository's
scripts/gen-mtls-dev-certs.shproduces a throwaway CA, server and client certificate, empty CRL and starter identity file for development. - Every accepted identity above lands in the audit log as an
auth/token_validationsuccess carrying"method":"mtls"; a rejection is logged as anauth/token_validationdenial whosemetadata.errorholds the message the client saw. Tutorial: verify the audit chain shows how to prove that trail has not been altered.
Built 2026-09-19. Source: levantar-ai/secronyx. Found a mistake? Tell us.