Module 12 — mTLS & Internal PKI
Updated 26 August 2026
Every module so far has answered one question: is this server really who it says it is? The server never asked the same question back. It just took your password and hoped.
This module turns the check around. The client presents a certificate too, so both sides prove who they are before a single byte of data moves. Then it covers the harder half — how you hand out and replace thousands of those certificates without it becoming a full-time job.
🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)
Prerequisite: Modules 01–11. You need Extended Key Usage from Module 03 (C4), your own CA from Module 05, the handshake from Module 06, the openssl verify error codes from Module 07, nginx from Module 08, CRLs from Module 09, and Module 11's conclusion that internal services do not belong on public certificates.
So far, TLS has worked like walking into a bank. You check the bank is real: the sign on the door, the branding, the address. That is the server certificate.
Then you walk up to the counter and prove who you are with a password. A password is a secret you say out loud. Anyone who overhears it, or tricks you into saying it at the wrong counter, can now be you.
mTLS replaces that with a staff pass. You do not say anything. You hold up a card that was issued to you, and the card proves itself using a private key that never leaves your pocket. Nothing is spoken, so nothing can be overheard.
And the door is fussy. It checks who issued the card, whether the card is the right type for this door, whether it has expired, and whether it has been reported lost. That is the whole of Part A.
mkdir -p ~/tls-lab/m12 && cd ~/tls-lab/m12Part C uses nginx. If you do not have it, install it (sudo apt-get install -y nginx / brew install nginx) — or skip the nginx exercises and read the outputs, which were all captured from a real run. On a work laptop, installing nginx is normally fine because we never start the system service; we run it by hand with our own config file and stop it again.
To stop everything and clean up at any time:
pkill -f 'nginx -c /tmp' 2>/dev/null; pgrep -f 'openssl s_server' | xargs -r kill
rm -rf ~/tls-lab/m12All output in this module was produced on OpenSSL 3.0.13, nginx 1.24.0 and curl 8.5.0.
Part A · What mTLS actually is
A1 · One-way TLS proves the server — mTLS proves both sides
A password is something you say. To use it you must reveal it. The other side must store something derived from it. If they are careless, or if you say it to the wrong person, it is gone.
A staff pass is something you hold. You never hand it over and you never read it out. It proves itself, and the thing that makes it work — the chip inside — never leaves the card.
That is exactly the difference between a bearer token and a client certificate. A client certificate is never sent as a secret, because it is not a secret. The secret is the private key, and the private key stays on the client machine forever.
Here is the difference in one table.
| Ordinary TLS | mTLS | |
|---|---|---|
| Server proves itself | Yes — sends a certificate | Yes — sends a certificate |
| Client proves itself | No. The connection is set up first, then the app asks for a password or token | Yes — sends a certificate too, during the handshake |
| When identity is known | After the connection, inside the application | Before the connection completes. An unknown client never gets to send a request |
| What is sent over the wire | The secret itself — password, API key, bearer token | A public certificate plus a signature. The private key is never transmitted |
| If the secret leaks | Anyone holding it is you, anywhere, until it is rotated | There is nothing to leak in transit. Stealing the identity means stealing a file from a machine |
With a password, the attacker gets a full TCP connection, a completed TLS handshake, and the right to send requests at your login page all day.
With mTLS, a client with no valid certificate is dropped during the handshake. There is no request, no login page, no rate limit to defeat, and usually no log line in the application at all — because from the application's point of view nothing ever happened.
That is why mTLS is the default for service-to-service traffic. It moves the front door outwards, in front of everything.
🧪 Exercise A1.1 — Build the two things every mTLS setup needs: a server and a way to talk to it
cd ~/tls-lab/m12
# a CA that signs SERVER certificates
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out server-ca.key
openssl req -x509 -new -key server-ca.key -sha256 -days 3650 -out server-ca.crt \
-subj "/CN=Lab Server CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
# the server's own certificate
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out server.key
openssl req -new -key server.key -out server.csr -subj "/CN=api.lab.test"
cat > srv.cnf <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:api.lab.test,DNS:localhost,IP:127.0.0.1
EOF
openssl x509 -req -in server.csr -CA server-ca.crt -CAkey server-ca.key -CAcreateserial \
-days 90 -sha256 -extfile srv.cnf -out server.crt
# start it, with NO client authentication at all
nohup openssl s_server -accept 4433 -cert server.crt -key server.key -www -quiet > s1.log 2>&1 &
SRV=$!; sleep 1
curl -s --cacert server-ca.crt --resolve api.lab.test:4433:127.0.0.1 \
https://api.lab.test:4433/ -o /dev/null -w "no client cert -> HTTP %{http_code}\n"
kill $SRV✅ Expected result — click to reveal
no client cert -> HTTP 200What to read out of this — the server let in a complete stranger, and that is the normal, correct behaviour of every website you have ever used.
- The server never asked who you were. It proved itself, and that was the end of the conversation about identity.
- --resolve is doing a small but important job. It tells curl to send api.lab.test to 127.0.0.1 without touching /etc/hosts. Module 07 introduced it; every exercise in this module uses it, so nothing on your machine is edited.
- -www makes openssl s_server behave like a tiny web server that replies with a status page. It is not a real web server — it exists so you can see a real HTTP response.
🔑 This is the baseline to compare everything against. Right now, anyone who can reach port 4433 gets a 200. In A3 the same command will fail during the handshake — and understanding why it fails, and where, is the whole point of the module.
💡 Now imagine this at 500 hosts. Five hundred services, all reachable by anything inside the network, all relying on the application to check a token. One service that forgets the check, or checks it wrongly, is a door into everything. mTLS makes the check a property of the connection rather than of each application's code — which means it cannot be forgotten in one place.
🎯 Interview questions — What mTLS is
Q. What is mTLS and how is it different from normal TLS?
In normal TLS only the server presents a certificate, so only the server's identity is proved. The client is anonymous at the TLS layer, and the application authenticates it afterwards with a password, API key or token.
In mTLS the server also asks the client for a certificate, and the client proves it holds the matching private key. Both identities are established during the handshake, before any application data is sent.
The detail worth adding: the point people miss is when the check happens. With a bearer token the attacker still gets a completed connection and can hammer your endpoint; with mTLS an unknown client is rejected in the handshake and never reaches the application at all. That also means your application logs will not show the attempt — the evidence lives in the proxy or load balancer, which is something to plan for before you deploy it.
Q. Why is a client certificate better than an API key?
An API key is a bearer secret: it must be transmitted to be used, it must be stored by both sides, and anyone who obtains it becomes the client from anywhere in the world. A client certificate is not transmitted as a secret at all — the certificate is public, and the client proves possession of the private key by signing something. The private key never leaves the machine.
That changes the theft model completely. Stealing an API key can be as easy as reading a log file, a URL, or an environment variable in a crash dump. Stealing a certificate identity means getting a file off a specific host.
The detail worth adding: the honest caveat is that a private key sitting in a file on disk is still a file on disk, so mTLS raises the bar rather than removing the problem. What removes most of it is short lifetimes plus automatic rotation — a stolen key that expires in an hour is worth far less than an API key that has been valid since 2021. That combination is Part D, and it is the answer that shows you have run this rather than read about it.
A2 · What changes in the handshake
A good doorman does not just say "show me some ID". That would leave you rummaging through a wallet full of cards, guessing which one he wants.
He says: "I need a pass issued by Lab Security Services." Now you know exactly which card to pull out, and you do not waste time offering the wrong one.
TLS does the same. When the server asks for a client certificate, it also sends the list of CAs it will accept. Your client uses that list to pick the right certificate — or to work out that it has none that will do.
Module 06 walked through the handshake. mTLS adds exactly two messages, and takes nothing away.
| Message | What it does |
|---|---|
| CertificateRequest server → client | "Send me a certificate." It also carries the acceptable CA names and the signature algorithms the server will accept |
| Certificate client → server | The client's certificate, plus any intermediates needed to build the chain. May be empty if the client has nothing suitable |
| CertificateVerify client → server | A signature over the handshake so far, made with the client's private key. This is the actual proof — the certificate alone proves nothing |
So the certificate says "here is the identity I claim", and CertificateVerify says "and here is the proof I own it". If you ever wonder why a stolen certificate file alone is harmless, this is why — without the key, the signature cannot be produced.
🧪 Exercise A2.1 — Watch the server ask, and see which CA it names
cd ~/tls-lab/m12
# a CA that signs CLIENT certificates (Part B explains why it is separate)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out client-ca.key
openssl req -x509 -new -key client-ca.key -sha256 -days 3650 -out client-ca.crt \
-subj "/CN=Lab Client CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
# -Verify 1 means: REQUIRE a client certificate, allow 1 intermediate
nohup openssl s_server -accept 4433 -cert server.crt -key server.key \
-CAfile client-ca.crt -Verify 1 -www -quiet > sa.log 2>&1 &
SRV=$!; sleep 1
echo | openssl s_client -connect 127.0.0.1:4433 -servername api.lab.test \
-CAfile server-ca.crt 2>&1 \
| sed -n '/Acceptable client certificate CA names/,/Peer signing digest/p' | head -4
kill $SRV✅ Expected result — click to reveal
Acceptable client certificate CA names
CN = Lab Client CA
Requested Signature Algorithms: ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:Ed25519:...
Shared Requested Signature Algorithms: ECDSA+SHA256:ECDSA+SHA384:...What to read out of this — the server told you, in plain text, exactly which CA it trusts for clients.
- Acceptable client certificate CA names is the CertificateRequest message, decoded for you. The server is advertising CN = Lab Client CA.
- This list is public. Anyone who connects can read it, with no certificate of their own. That is worth knowing: your internal CA's name is visible to anyone who can reach the port.
- Requested Signature Algorithms is the server saying which key types and hashes it will accept for the CertificateVerify signature. A client with an old key type that is not on this list cannot authenticate even if its certificate is perfect.
🔑 This one command answers the most common mTLS support question there is. When somebody says "my client certificate is being rejected", run this first. If the CA name on the server does not match the issuer of their certificate, you have found the problem in ten seconds — and it is the problem about half the time.
💡 In nginx this list comes from the ssl_client_certificate file. Put a 200-certificate bundle there and the server advertises all 200 names on every handshake — which bloats the handshake and, in a browser, produces an enormous certificate-picker dialogue. Part C covers the directive that avoids this.
A3 · What the server actually checks
Who issued this? A pass from a company he has never heard of is refused, however genuine it looks.
Is it the right kind of pass? A visitor pass is real, and it is still not a staff pass. Wrong type, refused.
Is it in date? Expired yesterday is expired.
Has it been reported lost? He glances at the list on his desk.
A client certificate goes through the same four checks, and any one of them failing ends the connection.
This is Module 07's validation, pointed the other way. Almost everything carries over — with one important difference.
| Check | What it means for a client certificate |
|---|---|
| Chain to a trusted CA | Must chain to a CA the server trusts for clients. This is a separate list from the server's own trust store |
| Signature and dates | Identical to Module 07. Signature valid, notBefore passed, notAfter not passed |
| Extended Key Usage | Must allow clientAuth. A certificate marked serverAuth only is not valid for a client |
| Revocation | Checked against a CRL you supply. Unlike the public web (Module 09), this one actually works — covered in C5 |
| The hostname | ❌ Not checked, and there is nothing to check it against. A client has no hostname. This is the big difference |
If the server trusts Lab Client CA, then every certificate that CA has ever issued gets in. All of them. The intern's laptop, the decommissioned test service, the contractor's script from 2023.
Verifying the certificate is authentication. Deciding what that identity is allowed to reach is authorisation, and TLS does not do it. You have to write it.
Teams skip this constantly, because "we have mTLS" feels like access control. It is not. Section C3 builds the missing half.
🧪 Exercise A3.1 — Two failures and one success
cd ~/tls-lab/m12
# a client certificate, with the EKU that matters
cat > cli.cnf <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=clientAuth
EOF
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out payments-svc.key
openssl req -new -key payments-svc.key -out payments-svc.csr -subj "/CN=payments-svc/O=Lab Ltd"
openssl x509 -req -in payments-svc.csr -CA client-ca.crt -CAkey client-ca.key -CAcreateserial \
-days 30 -sha256 -extfile cli.cnf -out payments-svc.crt
# a certificate from a CA the server has never heard of
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rogue-ca.key
openssl req -x509 -new -key rogue-ca.key -sha256 -days 365 -out rogue-ca.crt \
-subj "/CN=Rogue CA" -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign"
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rogue.key
openssl req -new -key rogue.key -out rogue.csr -subj "/CN=rogue-svc/O=Elsewhere Ltd"
openssl x509 -req -in rogue.csr -CA rogue-ca.crt -CAkey rogue-ca.key -CAcreateserial \
-days 30 -sha256 -extfile cli.cnf -out rogue.crt
# NOTE -verify_return_error. Without it, s_server PRINTS the error and lets the client
# in anyway, which makes the whole exercise silently pointless.
nohup openssl s_server -accept 4433 -cert server.crt -key server.key \
-CAfile client-ca.crt -Verify 1 -verify_return_error -www -quiet > sa.log 2>&1 &
SRV=$!; sleep 1
R="--resolve api.lab.test:4433:127.0.0.1"; C="--cacert server-ca.crt"
echo "--- (a) no client certificate at all:"
curl -sS $C $R https://api.lab.test:4433/ 2>&1 | head -1
echo "--- (b) a certificate from an unrelated CA:"
curl -sS $C --cert rogue.crt --key rogue.key $R https://api.lab.test:4433/ 2>&1 | head -1
echo "--- (c) the proper client certificate:"
curl -s $C --cert payments-svc.crt --key payments-svc.key $R https://api.lab.test:4433/ \
| grep -A1 "Client certificate" | head -2
echo "--- what the server logged:"
grep -E "verify error|depth=" sa.log | tail -4
kill $SRV✅ Expected result — click to reveal
--- (a) no client certificate at all:
curl: (56) OpenSSL SSL_read: OpenSSL/3.0.13: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
--- (b) a certificate from an unrelated CA:
curl: (56) OpenSSL SSL_read: OpenSSL/3.0.13: error:0A000418:SSL routines::tlsv1 alert unknown ca, errno 0
--- (c) the proper client certificate:
Client certificate
Certificate:
--- what the server logged:
depth=0 CN = rogue-svc, O = Elsewhere Ltd
verify error:num=20:unable to get local issuer certificate
depth=1 CN = Lab Client CA
depth=0 CN = payments-svc, O = Lab LtdWhat to read out of this — the two failures fail for completely different reasons, and the error text tells you which.
- certificate required — you sent nothing and the server insisted. This is the alert you will see most often in real life, and it means the client did not present a certificate, not that the certificate was bad.
- unknown ca — you sent something, and it did not chain to Lab Client CA. The rogue certificate is perfectly valid and correctly formed; it is simply from the wrong CA for this purpose. Wrong issuer, not wrong maths. The server log names the real reason — verify error:num=20, the same unable to get local issuer certificate from Module 07, now applied to a client.
- Client certificate followed by the full certificate — success. s_server echoes back what it received, which makes it an excellent debugging tool.
- ⚠️ The -verify_return_error flag is not optional, and leaving it out is a real trap. Without it, openssl s_server prints verify error:num=20 to its log and completes the handshake anyway. Your test then looks like it proves mTLS is working, when the server is in fact letting everyone in. If you build a lab and every certificate seems to be accepted, this flag is almost always the reason.
🔑 Learn these two alert strings. certificate required and unknown ca cover the large majority of mTLS failures, and they point at completely different fixes — the client is not configured versus the client is configured with the wrong certificate. Being able to tell them apart from a single line of curl output is genuinely most of mTLS troubleshooting.
💡 Notice that case (b) failed at the CA check, not the EKU check. The server never got as far as looking at Extended Key Usage, because the chain failed first. Validation stops at the first failure, so the error you see is the earliest problem, not necessarily the only one.
🎯 Interview questions — What the server checks
Q. What does a server verify when a client presents a certificate?
The same path validation as any certificate: the signature chains to a CA the server trusts for client authentication, the dates are valid, and the chain is well-formed. On top of that it checks Extended Key Usage allows clientAuth, and if a CRL is configured it checks the certificate has not been revoked.
The one thing it does not do is a hostname check. There is no hostname to check — a client is not reached by name, so there is nothing to compare a SAN against.
The detail worth adding: say clearly that the trust list for clients is separate from the server's own trust store, and that it should be. Trusting the public web PKI for client authentication would mean any certificate from any public CA gets in, which is a catastrophic misconfiguration and one that has happened in production. In nginx the directive is ssl_client_certificate, and it should contain your internal client CA and nothing else.
Q. Does mTLS give you authorisation?
No. It gives you a verified identity, and nothing more. If the server trusts a CA, every certificate that CA has issued will pass — including ones issued to services that have no business talking to this endpoint.
Authorisation is a separate decision you have to implement: map the certificate's identity (the subject DN, a SAN, or an OU) to a set of permissions, and enforce it in the proxy or the application.
The detail worth adding: the failure mode is subtle and common. A team enables mTLS, sees that unknown clients are rejected, and concludes access control is done — but internally every service can now reach every other service, because they all share one client CA. The fix is either an explicit allow-list keyed on the identity, or issuing from per-purpose CAs or per-namespace identities so that the trust relationship itself carries meaning. SPIFFE in Part D exists largely to make that second option practical.
Q. A client certificate is rejected with "unknown ca". What is your first move?
Compare the issuer of the client's certificate with the CA list the server advertises. openssl x509 -in client.crt -noout -issuer gives you one side; connecting with openssl s_client and reading the Acceptable client certificate CA names block gives you the other. If they do not match, that is the answer.
If they do match, the next suspect is a missing intermediate: the client is sending only its leaf certificate and the server cannot build the chain. The error moves to unable to verify the first certificate, OpenSSL error 21.
The detail worth adding: mention that this is the mirror image of Module 08's chain-order trap. There, the server had to send its intermediates; here the client does, and the same rule applies — each side sends everything except the root. It is a satisfying answer in an interview because it shows you see the symmetry rather than treating client certificates as a separate topic.
Part B · Designing the client CA
B1 · Why clients need their own CA
A building could print both from one machine, using one design, with one number series. It would work. And the day someone works out how to get a visitor badge printed, they have a staff pass.
Keeping them separate means the two kinds of card cannot be confused for one another, even by accident, even by a tired doorman.
Client certificates and server certificates are the same. Same technology, completely different purpose, and they should come from different CAs.
There are three reasons, and they get stronger as you go down.
One — different lifecycles. Server certificates come from a public CA, live 47–90 days, and renew through ACME (Module 10). Client certificates come from you, may live hours or a year, and are issued when a service is deployed. Trying to run both from one place means one of them gets the wrong treatment.
Two — different revocation needs. Module 09 showed public revocation barely works. Internal revocation works perfectly, because you control both the CA and every server that checks. That only holds if the CRL is small and specific to your clients.
Three, and this is the real one — the trust list is a blast radius. Whatever you put in ssl_client_certificate is a list of everyone who can get in. If that file contains a CA which also signs server certificates, then every server certificate it ever issued is a potential key to your door.
X.509 has a defence against exactly this, and it works.
A certificate with extendedKeyUsage = serverAuth may prove it is a server. A certificate with clientAuth may prove it is a client. A certificate with neither may do both.
This is not documentation. OpenSSL and nginx both refuse the wrong purpose, with verify error 26 — unsuitable certificate purpose. You are about to see it.
So EKU gives you defence in depth. Separate CAs is the design; EKU is the guardrail that catches you when the design slips.
🧪 Exercise B1.1 — Try to walk in using a server's certificate
cd ~/tls-lab/m12
# a server that (wrongly) trusts the SERVER CA for client authentication
nohup openssl s_server -accept 4433 -cert server.crt -key server.key \
-CAfile server-ca.crt -Verify 1 -verify_return_error -www -quiet > se.log 2>&1 &
SRV=$!; sleep 1
# server.crt genuinely chains to server-ca.crt. Will it be accepted as a client?
curl -sS --cacert server-ca.crt --cert server.crt --key server.key \
--resolve api.lab.test:4433:127.0.0.1 https://api.lab.test:4433/ 2>&1 | head -1
echo "--- what the server logged:"
grep -E "verify error|depth=" se.log | tail -2
kill $SRV✅ Expected result — click to reveal
curl: (56) OpenSSL SSL_read: OpenSSL/3.0.13: error:0A000413:SSL routines::sslv3 alert unsupported certificate, errno 0
--- what the server logged:
depth=0 CN = api.lab.test
verify error:num=26:unsuitable certificate purposeWhat to read out of this — the chain was perfect and it was still refused.
- verify error:num=26 — unsuitable certificate purpose. Add this to the verify-code table from Module 07. It means the maths is fine, the issuer is trusted, and this certificate is not allowed to do this job.
- Nothing about the chain was wrong. server.crt really was issued by server-ca.crt, and the server really did trust that CA. The single line that stopped it was extendedKeyUsage = serverAuth in the certificate.
- The alert on the wire is unsupported certificate, which is unhelpfully vague. The useful detail is always on the server side, in the log. Remember that when someone sends you a screenshot of a client error.
🔑 This is why you should always set EKU explicitly. A certificate with no EKU extension is treated as valid for every purpose — so leaving it out silently removes this protection. One line in your extensions file is the difference between a guardrail and no guardrail.
💡 Now imagine this at 500 hosts. Suppose someone points ssl_client_certificate at the public CA bundle "to make it work". Every certificate issued by every public CA on earth now chains successfully — but almost all of them are serverAuth only, so EKU quietly refuses them and the mistake looks like it is working. The day someone obtains a real clientAuth certificate from a public CA, they walk straight in. Do not rely on the guardrail; get the trust list right.
🎯 Interview questions — Separating the CAs
Q. Should client and server certificates come from the same CA?
No. Keep them separate. The list of CAs a server trusts for client authentication is effectively the list of everyone who may connect, so it should contain your client CA and nothing else. If it also signs server certificates, every one of those becomes a potential credential.
They also have completely different lifecycles: server certificates come from a public CA and renew via ACME, while client certificates are yours and are issued as part of deployment.
The detail worth adding: mention extendedKeyUsage as the second line of defence. A serverAuth-only certificate offered for client authentication is refused with verify error 26, unsuitable certificate purpose — so EKU catches the mistake even when the trust configuration is wrong. But say clearly that it is a guardrail, not the design: a certificate with no EKU extension is valid for all purposes, so the protection disappears the moment someone omits it.
Q. What is the worst mTLS misconfiguration you can think of?
Pointing the client trust store at the public CA bundle. It usually happens because someone is debugging a unknown ca error and swaps in /etc/ssl/certs/ca-certificates.crt to make it go away. The moment they do, the server trusts every publicly-trusted CA in the world for client authentication.
In practice most public certificates are serverAuth only and get refused by EKU, so it appears to keep working — which is what makes it so dangerous. It is a hole that does not announce itself.
The detail worth adding: the general lesson is that in mTLS the trust file is the access control list, and it deserves to be reviewed like one. A good habit is to assert its contents in configuration management — this file contains exactly these CA certificates, and a diff is a change that needs approval, not a routine edit.
B2 · What identity to put inside the certificate
A staff pass could say "Zaeem". It could say "Zaeem, Platform Team". It could say "employee 4417".
Which one you choose decides what the doorman can do with it. If the pass only carries a name, he cannot enforce "Platform Team may enter the server room" — he would need a separate list mapping names to teams, and that list will go stale.
Whatever you print on the certificate is what your rules can be written against. Choose it before you issue ten thousand of them, because changing it later means reissuing every one.
Your options, in rough order of how modern they are:
| Where the identity lives | What it is like to work with |
|---|---|
| CN in the Subject | The traditional choice — CN=payments-svc. Simple, universally supported, and awkward to parse because the DN is a structured thing being handled as a string |
| O or OU in the Subject | Useful for coarse grouping — OU=platform. Lets you write one rule for a whole team rather than one per service |
| A URI SAN | The modern choice, and what SPIFFE uses: URI:spiffe://lab.test/ns/payments/sa/checkout. A single unambiguous string, structured, easy to match on |
| An email SAN | The right choice for people rather than services — a client certificate issued to a human |
| The serial number | Unambiguous and completely meaningless to a human. Good for audit trails, terrible for rules |
openssl x509 -subject prints CN = payments-svc, O = Lab Ltd.
nginx's $ssl_client_s_dn prints O=Lab Ltd,CN=payments-svc.
The order is reversed and the spaces are gone. They are the same name — nginx uses the RFC 2253 / RFC 4514 form, which writes a DN from the most specific component to the least, and OpenSSL's default output does the opposite.
Every allow-list built by copying the output of openssl x509 -subject fails silently for this reason. The fix is one flag: openssl x509 -subject -nameopt RFC2253 gives you the string nginx will actually see.
🧪 Exercise B2.1 — Print the same name three ways, and build the SPIFFE-style version
cd ~/tls-lab/m12
echo "=== the same DN, three ways:"
openssl x509 -in payments-svc.crt -noout -subject
openssl x509 -in payments-svc.crt -noout -subject -nameopt RFC2253
echo "=== now a certificate that carries its identity in a URI SAN instead:"
cat > svid.cnf <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth,clientAuth
subjectAltName=critical,URI:spiffe://lab.test/ns/payments/sa/checkout
EOF
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out svid.key
openssl req -new -key svid.key -out svid.csr -subj "/"
openssl x509 -req -in svid.csr -CA client-ca.crt -CAkey client-ca.key -CAcreateserial \
-days 1 -sha256 -extfile svid.cnf -out svid.crt
openssl x509 -in svid.crt -noout -subject -ext subjectAltName,extendedKeyUsage,basicConstraints
openssl x509 -in svid.crt -noout -dates
echo "=== pull the identity out in one line:"
openssl x509 -in svid.crt -noout -ext subjectAltName | sed -n 's/.*URI:\(spiffe:[^,]*\).*/\1/p'✅ Expected result — click to reveal
=== the same DN, three ways:
subject=CN = payments-svc, O = Lab Ltd
subject=O=Lab Ltd,CN=payments-svc
=== now a certificate that carries its identity in a URI SAN instead:
subject=
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Extended Key Usage:
TLS Web Server Authentication, TLS Web Client Authentication
X509v3 Subject Alternative Name: critical
URI:spiffe://lab.test/ns/payments/sa/checkout
notBefore=Aug 26 13:19:14 2026 GMT
notAfter=Aug 27 13:19:14 2026 GMT
=== pull the identity out in one line:
spiffe://lab.test/ns/payments/sa/checkoutWhat to read out of this — look at line 2 and line 3, then look at the empty Subject.
- CN = payments-svc, O = Lab Ltd versus O=Lab Ltd,CN=payments-svc. Reversed order, no spaces. Both describe the same certificate. Use -nameopt RFC2253 whenever you are building a rule that a proxy will evaluate.
- subject= is empty on the SVID, and that is deliberate. There is no CN at all. The identity is entirely in the URI SAN — which is why the SAN is marked critical: a client that cannot understand the SAN must not accept the certificate, because without it the certificate identifies nobody.
- serverAuth, clientAuth together. A workload is both. payments-svc calls other services and is called by them, so its one certificate has to work in both directions.
- One day of validity. That is not a typo, and it is the norm for workload identity. Part D explains how anything can be operated that way.
🔑 The URI SAN is easier to work with than a DN, and that is the whole argument for it. One flat string, one obvious way to write it, no ordering question, no escaping question, and it can encode structure — namespace, service account, environment — that a rule can pattern-match on. If you are designing an internal PKI now, put the identity in a URI SAN.
💡 Now imagine this at 500 hosts. With a DN scheme, an allow-list of 500 services is 500 exact strings that must each be spelled correctly in the proxy's config. With a URI scheme it can be one pattern — spiffe://lab.test/ns/payments/*. That difference is the difference between a rule set humans can maintain and one nobody dares touch.
🎯 Interview questions — Identity in the certificate
Q. Where would you put the identity in a client certificate, and why?
In a URI SAN, if you are designing it today — spiffe://trust-domain/path. It is one unambiguous string, it can encode structure like namespace and service account, and it pattern-matches cleanly in proxy rules. The Subject can be left empty, in which case the SAN must be marked critical.
The traditional alternative is the CN, sometimes with O or OU for grouping. It works everywhere and is what most existing systems use, but a DN is a structured object being handled as a string, which invites bugs.
The detail worth adding: the concrete bug is worth naming because it is so common. openssl x509 -subject prints CN = payments-svc, O = Lab Ltd, while nginx's $ssl_client_s_dn prints the RFC 4514 form O=Lab Ltd,CN=payments-svc — reversed, with the spaces removed. Allow-lists built by copying OpenSSL's output silently never match. -nameopt RFC2253 gives you the string the proxy will actually see, and knowing that flag is a good sign you have debugged this for real.
Q. Why would a certificate have both serverAuth and clientAuth?
Because in service-to-service traffic a workload is both. payments-svc receives calls, so it needs serverAuth; it also makes calls to other services, so it needs clientAuth. Issuing one certificate that does both is simpler than managing two, and it is what SPIFFE's X.509-SVID specification recommends.
For a certificate issued to a person, or to something that only ever initiates connections, clientAuth alone is the correct and tighter choice.
The detail worth adding: the trade-off is that a dual-purpose certificate cannot be used to enforce direction. If a workload's key is stolen, the attacker can both impersonate the service and call other services as it. Some high-security designs deliberately split them for that reason, at the cost of doubling the certificates to manage. Knowing the trade-off exists — rather than treating serverAuth,clientAuth as the default answer — is the mark of someone who has thought about it.
B3 · Issuing more than a handful — the CA ledger
Printing passes is easy. Knowing which passes exist is the hard part.
Reception keeps a book: pass number, name, date issued, date it expires, and whether it has been reported lost. Without the book you can still print passes — you just cannot ever answer "is pass 1042 still valid?" or "cancel the pass we gave that contractor".
openssl ca keeps that book. It is a text file called index.txt, and Module 05 introduced it. You cannot revoke a certificate you never wrote down.
Module 04 issued certificates with openssl x509 -req. That is fine for two or three. It records nothing, so it cannot revoke anything.
openssl ca is the same signing operation with a ledger attached. Each issuance appends a line to index.txt:
V 260925131828Z 1000 unknown /CN=payments-svc/O=Lab Ltd
│ │ │ │ └── the subject
│ │ │ └────────── filename of the stored copy
│ │ └────────────────── serial number
│ └────────────────────────────────────── expiry date
└────────────────────────────────────────── status: V = valid, R = revoked, E = expired🧪 Exercise B3.1 — Issue through a proper CA, then cancel one certificate
cd ~/tls-lab/m12
mkdir -p ca/newcerts && touch ca/index.txt && echo 1000 > ca/serial && echo 1000 > ca/crlnumber
cat > ca.cnf <<EOF
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = $PWD/ca
database = \$dir/index.txt
new_certs_dir = \$dir/newcerts
serial = \$dir/serial
crlnumber = \$dir/crlnumber
certificate = $PWD/client-ca.crt
private_key = $PWD/client-ca.key
default_md = sha256
default_days = 30
default_crl_days = 7
policy = pol
copy_extensions = none
[ pol ]
commonName = supplied
organizationName = optional
[ cli_ext ]
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=clientAuth
EOF
# a second client, then reissue both THROUGH the CA so the ledger knows them
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out reporting-svc.key
openssl req -new -key reporting-svc.key -out reporting-svc.csr -subj "/CN=reporting-svc/O=Lab Ltd"
for n in payments-svc reporting-svc; do
openssl ca -batch -config ca.cnf -extensions cli_ext -in $n.csr -out $n.crt
done
echo "=== the ledger:"; cat ca/index.txt
openssl ca -batch -config ca.cnf -revoke reporting-svc.crt -crl_reason keyCompromise
openssl ca -batch -config ca.cnf -gencrl -out client-ca.crl
echo "=== the ledger after revoking one:"; cat ca/index.txt
echo "=== the CRL:"; openssl crl -in client-ca.crl -noout -text | head -12✅ Expected result — click to reveal
=== the ledger:
V 260925131828Z 1000 unknown /CN=payments-svc/O=Lab Ltd
V 260925131828Z 1001 unknown /CN=reporting-svc/O=Lab Ltd
=== the ledger after revoking one:
V 260925131828Z 1000 unknown /CN=payments-svc/O=Lab Ltd
R 260925131828Z 260826131828Z,keyCompromise 1001 unknown /CN=reporting-svc/O=Lab Ltd
=== the CRL:
Certificate Revocation List (CRL):
Version 2 (0x1)
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN = Lab Client CA
Last Update: Aug 26 13:18:28 2026 GMT
Next Update: Sep 2 13:18:28 2026 GMT
CRL extensions:
X509v3 CRL Number:
4096
Revoked Certificates:
Serial Number: 1001
Revocation Date: Aug 26 13:18:28 2026 GMTWhat to read out of this — your dates and serial hex will differ; the structure will not.
- V became R, with a revocation date and the reason. That single character is the entire state change, exactly as Module 09 described.
- Serial numbers are now sequential and recorded — 1000, 1001. With openssl x509 -req -CAcreateserial they were random and written nowhere useful. Revocation works on serial numbers, so a serial nobody recorded cannot be revoked.
- Next Update is seven days out, from default_crl_days = 7. Module 09's warning applies here too: a CRL past its Next Update is treated as broken, and a broken CRL will lock out every one of your clients at once.
- The subject came out as /CN=payments-svc/O=Lab Ltd, following the order in the [ pol ] section rather than the order you typed. This is where the RFC 4514 reversal from B2 comes from — check your allow-list strings after switching to openssl ca.
⚠️ copy_extensions = none is deliberate and important. With copy set, anything a requester puts in their CSR — including basicConstraints CA:TRUE — is copied into the issued certificate. That would let anyone who can request a client certificate mint themselves a CA. Module 05 covered this; it matters even more here, because client CSRs often come from other teams.
💡 Now imagine this at 500 hosts. index.txt is a flat text file with no locking. Two issuances at the same second corrupt it. That is not a criticism of OpenSSL — it was never meant to be a service — it is the exact point at which you stop hand-rolling and adopt one of the tools in Part D.
🎯 Interview questions — Running the CA
Q. Why use openssl ca rather than openssl x509 -req?
Because openssl ca keeps a database. Every issuance is recorded in index.txt with its serial number, expiry, subject and status, and serial numbers come from a counter rather than being random. That record is what makes revocation possible — a CRL lists serial numbers, so a certificate whose serial was never recorded cannot meaningfully be revoked.
openssl x509 -req just signs. It is fine for a lab or a one-off, and it is the wrong tool for anything you will later need to manage.
The detail worth adding: also mention copy_extensions. It defaults to none for good reason: with it set to copy, extensions from the CSR are carried into the certificate, so a requester could include basicConstraints=CA:TRUE and have your CA issue them a CA certificate. In an internal PKI where CSRs arrive from other teams, that is a privilege-escalation path, and it is a detail interviewers use to separate people who have read a tutorial from people who have run a CA.
Q. At what point would you stop using OpenSSL to run your CA?
As soon as issuance stops being a human action. index.txt is a flat file with no locking, so concurrent issuance corrupts it; the CA private key sits on disk with whatever permissions someone remembered to set; there is no audit trail beyond the file; and renewal is entirely manual.
Roughly, OpenSSL is fine while a person issues certificates occasionally and remembers each one. Once certificates are created by a deployment pipeline, or once lifetimes are short enough that renewal must be automatic, you need a service.
The detail worth adding: frame it as the same transition Module 10 described for public certificates. Once lifetimes drop below what a human can track, automation is not an efficiency improvement, it is the only way the system can work at all. That is why cert-manager, Vault and SPIRE exist, and it is why they all issue short-lived certificates by default rather than offering it as an option.
Part C · Deploying it — nginx, Apache and the traps
C1 · Turning it on, and what you get back
Off: don't ask anyone for ID.
On: ask everyone, and turn away anyone who has none.
Optional: ask everyone, let them all in, and write on the visitor sheet whether they showed anything. Somebody further inside decides what to do about it.
Those are literally the three values of ssl_verify_client, and choosing between on and optional is the most consequential decision in this part.
Two directives turn mTLS on in nginx, and there is no third:
| Directive | What it does |
|---|---|
| ssl_client_certificate | The CAs you trust to sign client certificates. This list is sent to every client so it can pick the right certificate |
| ssl_trusted_certificate | The same, but not advertised to clients. Use it when the list is long or when you would rather not publish your CA names |
| ssl_verify_client | on | off | optional | optional_no_ca. Default is off — configuring the CA file alone does nothing |
| ssl_verify_depth | How many intermediates are allowed. nginx defaults to 1; Apache defaults to 10 |
| ssl_crl | A CRL file of revoked client certificates. Covered in C5 |
Once a client authenticates, nginx hands you the identity in variables:
| Variable | Value |
|---|---|
| $ssl_client_verify | SUCCESS, NONE (no certificate sent), or FAILED:reason |
| $ssl_client_s_dn | The subject DN, in RFC 4514 form — reversed and unspaced, as B2 warned |
| $ssl_client_i_dn | The issuer DN, same format |
| $ssl_client_serial | The serial number, uppercase hex |
| $ssl_client_v_remain | Days until the certificate expires. Excellent thing to log or alarm on |
| $ssl_client_escaped_cert | The whole certificate, URL-encoded, ready to pass to an application |
Apache does the same thing with different names: SSLVerifyClient none|optional|require|optional_no_ca, SSLCACertificateFile, SSLVerifyDepth, and the identity arrives as CGI environment variables SSL_CLIENT_VERIFY, SSL_CLIENT_S_DN, SSL_CLIENT_I_DN and so on — but only if you also set SSLOptions +StdEnvVars, which is off by default for performance.
🧪 Exercise C1.1 — A real nginx doing real mTLS
cd ~/tls-lab/m12
mkdir -p logs
cat > nginx.conf <<EOF
worker_processes 1;
error_log $PWD/logs/error.log info;
pid $PWD/logs/nginx.pid;
events { worker_connections 64; }
http {
access_log off;
server {
listen 8443 ssl;
server_name api.lab.test;
ssl_certificate $PWD/server.crt;
ssl_certificate_key $PWD/server.key;
ssl_client_certificate $PWD/client-ca.crt; # who may sign client certificates
ssl_verify_client on; # and one is REQUIRED
location / {
default_type text/plain;
return 200 "verify=\$ssl_client_verify\nsubject=\$ssl_client_s_dn\nissuer=\$ssl_client_i_dn\nserial=\$ssl_client_serial\nexpires_in=\$ssl_client_v_remain days\n";
}
}
}
EOF
nginx -t -c $PWD/nginx.conf
nginx -c $PWD/nginx.conf && sleep 1
R="--resolve api.lab.test:8443:127.0.0.1"; C="--cacert server-ca.crt"
echo "=== (a) no client certificate:"
curl -s -o /dev/null -w "HTTP %{http_code}\n" $C $R https://api.lab.test:8443/
curl -s $C $R https://api.lab.test:8443/ | head -6
echo "=== (b) with a valid client certificate:"
curl -s $C --cert payments-svc.crt --key payments-svc.key $R https://api.lab.test:8443/
nginx -s stop -c $PWD/nginx.conf✅ Expected result — click to reveal
nginx: the configuration file .../nginx.conf syntax is ok
nginx: configuration file .../nginx.conf test is successful
=== (a) no client certificate:
HTTP 400
<html>
<head><title>400 No required SSL certificate was sent</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>No required SSL certificate was sent</center>
<hr><center>nginx/1.24.0 (Ubuntu)</center>
=== (b) with a valid client certificate:
verify=SUCCESS
subject=CN=payments-svc,O=Lab Ltd
issuer=CN=Lab Client CA
serial=38B99DC9309CBA95F8BD9531734E6A142D4A4609
expires_in=29 daysWhat to read out of this — three things, and the middle one is a surprise.
- 400 No required SSL certificate was sent. nginx completes the TLS handshake and then returns an HTTP 400, rather than rejecting at the TLS layer as openssl s_server did. That is a deliberate nginx design choice so it can give a readable error, and it means a browser shows a page rather than a blank connection failure.
- subject=CN=payments-svc,O=Lab Ltd — this run used the certificate from A3, issued with openssl x509 -req. After you switch to openssl ca in B3 the same certificate prints as O=Lab Ltd,CN=payments-svc, because the CA reordered the DN. The string your rules must match depends on how the certificate was issued. That is the B2 trap, appearing in the wild.
- expires_in=29 days is free monitoring. Log $ssl_client_v_remain on every request and you will never again be surprised by a client certificate expiring — which, in mTLS estates, is the single most common outage.
🔑 ssl_verify_client defaults to off. Setting ssl_client_certificate alone changes nothing at all — the server will happily serve everybody. This is the "we configured mTLS but never enabled it" failure, and it is common enough that the capstone in Part E is built to detect it.
💡 The certificate serial here is a long random hex string because -CAcreateserial generated it. After B3 it becomes 1000, 1001 and so on. Sequential serials make a CRL readable by a human; random ones do not.
C2 · The two traps that will actually catch you
Your pass says "issued by the Regional Office". The doorman only knows "Head Office". He is willing to believe the Regional Office is legitimate — but only if you also hand him the letter from Head Office that says so.
If you hand over just your pass, he has a card signed by a stranger. He cannot connect it to anyone he trusts, so he refuses.
It is the client's job to carry that letter, not the doorman's job to go looking for it.
Module 08 taught this rule for servers: send everything except the root. It is exactly the same for clients, and it is forgotten far more often, because --cert looks like it should take one file.
🧪 Exercise C2.1 — Break it, then fix it
cd ~/tls-lab/m12
# an intermediate CA under the client root, and a client issued by it
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out client-int.key
openssl req -new -key client-int.key -out client-int.csr -subj "/CN=Lab Client Issuing CA"
cat > int.cnf <<'EOF'
basicConstraints=critical,CA:TRUE,pathlen:0
keyUsage=critical,keyCertSign,cRLSign
EOF
openssl x509 -req -in client-int.csr -CA client-ca.crt -CAkey client-ca.key -CAcreateserial \
-days 1825 -sha256 -extfile int.cnf -out client-int.crt
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out deep-svc.key
openssl req -new -key deep-svc.key -out deep-svc.csr -subj "/CN=deep-svc/O=Lab Ltd"
openssl x509 -req -in deep-svc.csr -CA client-int.crt -CAkey client-int.key -CAcreateserial \
-days 30 -sha256 -extfile cli.cnf -out deep-svc.crt
# the fixed version: leaf FIRST, then the intermediate
cat deep-svc.crt client-int.crt > deep-svc-chain.crt
nginx -c $PWD/nginx.conf && sleep 1
R="--resolve api.lab.test:8443:127.0.0.1"; C="--cacert server-ca.crt"
echo "=== (a) client sends its leaf ONLY:"
curl -s -o /dev/null -w "HTTP %{http_code}\n" $C --cert deep-svc.crt --key deep-svc.key $R https://api.lab.test:8443/
grep -o "client SSL certificate verify error: ([^)]*)" logs/error.log | tail -1
echo "=== (b) same client, with the intermediate attached:"
curl -s $C --cert deep-svc-chain.crt --key deep-svc.key $R https://api.lab.test:8443/
nginx -s stop -c $PWD/nginx.conf✅ Expected result — click to reveal
=== (a) client sends its leaf ONLY:
HTTP 400
client SSL certificate verify error: (21:unable to verify the first certificate)
=== (b) same client, with the intermediate attached:
verify=SUCCESS
subject=CN=deep-svc,O=Lab Ltd
issuer=CN=Lab Client Issuing CA
serial=6073B53D919A264D6E6D2DF8E6F374B2CADB190C
expires_in=29 daysWhat to read out of this — the certificate was identical in both runs. Only what was sent alongside it changed.
- Error 21, unable to verify the first certificate. The same code Module 07 introduced and Module 08 saw on the server side. The server has the root; it does not have the intermediate; it cannot join the two.
- The fix is cat leaf.crt intermediate.crt > chain.crt, in that order. Leaf first, then up the chain, root omitted. Precisely the Module 08 rule, applied to the client.
- The error only appears in the server's log. The client sees a bare 400. When someone reports "mTLS is broken", the useful information is always on the server — this is worth saying out loud before anyone spends an hour reading client-side output.
⚠️ The second trap: ssl_verify_depth defaults to 1 in nginx and 10 in Apache. A depth of 1 allows exactly one intermediate, which covers the common root → issuing-CA → client shape. Add a second tier and nginx starts refusing chains that Apache accepts, with no obvious clue as to why. If you have a three-tier internal PKI, set ssl_verify_depth 2 explicitly and do not rely on the default.
🔑 The rule to memorise, because it covers both directions. Each side sends its own certificate plus every intermediate above it, and never the root. Servers do it with fullchain.pem (Module 08). Clients do it with a concatenated --cert file. One rule, two places.
💡 Now imagine this at 500 hosts. When an internal PKI rotates its intermediate, every client's chain file must be rebuilt — not just the leaf. Automation that renews only the leaf produces a fleet of certificates that are individually valid and collectively unusable, and it fails all at once on the day the old intermediate is removed from the server's trust file.
🎯 Interview questions — Deploying mTLS
Q. How do you enable mTLS in nginx?
Two directives. ssl_client_certificate points at the CA certificate you trust to sign client certificates, and ssl_verify_client on requires a valid client certificate. Optionally ssl_verify_depth if your PKI has more than one intermediate tier, and ssl_crl for revocation.
After that, $ssl_client_verify, $ssl_client_s_dn and the other $ssl_client_* variables carry the identity into your configuration or your application.
The detail worth adding: two defaults are worth naming because both cause real incidents. ssl_verify_client defaults to off, so configuring the CA file alone silently does nothing — the classic "we deployed mTLS" that never enforced anything. And ssl_verify_depth defaults to 1 in nginx while Apache defaults to 10, so a chain that works behind Apache can be rejected by nginx after a migration.
Q. A client certificate is valid, the CA is trusted, and nginx still returns 400. Why?
Most likely the client is sending only its leaf certificate and not the intermediate that issued it. The server has the root but cannot bridge the gap, so path building fails with OpenSSL error 21, unable to verify the first certificate. The fix is to concatenate leaf then intermediate into the file passed to --cert.
The other candidate is ssl_verify_depth, which defaults to 1 in nginx — a PKI with two intermediate tiers exceeds it.
The detail worth adding: the diagnostic habit matters as much as the answer. The client sees a bare 400; the reason only appears in nginx's error_log, and only at info level or lower for some of these messages. Saying "I'd raise the error log level and read the server side first" is a better answer than any amount of certificate theory, because it is what actually resolves the ticket.
C3 · optional — where authorisation gets written
With on, the doorman turns people away himself. Simple, and he can only apply one rule to the whole building.
With optional, he checks the pass, writes who you are on the sheet, and lets you into the lobby. The lobby is harmless. When you try a door that matters, that door reads the sheet.
This is how you get different rules for different places: the front page open to everyone, the API restricted to two services, the admin path restricted to one.
Recall the warning from A3: verifying a certificate is authentication, not authorisation. With ssl_verify_client on, every certificate your CA has ever issued reaches every endpoint. optional is how you separate the two decisions.
| Value | When to use it |
|---|---|
| on | The whole server is for authenticated clients only, and one rule fits all of it. Simplest, and the right default for an internal API |
| optional | Some paths are public and some are not, or you need per-identity rules. You must then enforce it yourself — nginx will not |
| optional_no_ca | Request a certificate but do not require it to chain to a trusted CA. For when something downstream does the verification — a rare and deliberate choice |
optional_no_ca is worse still: $ssl_client_verify will read FAILED:... for an untrusted certificate, and a check written as if ($ssl_client_verify = NONE) misses it, because a certificate was sent — it was just worthless.
Always test for SUCCESS, never for the absence of NONE.
🧪 Exercise C3.1 — Authentication and authorisation as two separate decisions
cd ~/tls-lab/m12
cat > nginx-authz.conf <<EOF
worker_processes 1;
error_log $PWD/logs/error-authz.log info;
pid $PWD/logs/nginx-authz.pid;
events { worker_connections 64; }
http {
access_log off;
# authentication says WHO. this map says WHETHER THEY MAY.
map \$ssl_client_s_dn \$allowed {
default 0;
"O=Lab Ltd,CN=payments-svc" 1;
}
server {
listen 8443 ssl;
server_name api.lab.test;
ssl_certificate $PWD/server.crt;
ssl_certificate_key $PWD/server.key;
ssl_client_certificate $PWD/client-ca.crt;
ssl_verify_client optional; # ask, but do not enforce here
location /public {
default_type text/plain;
return 200 "public: verify=\$ssl_client_verify\n";
}
location /private {
default_type text/plain;
if (\$ssl_client_verify != SUCCESS) { return 401 "401 no valid client certificate\n"; }
if (\$allowed = 0) { return 403 "403 \$ssl_client_s_dn is authenticated but not authorised\n"; }
return 200 "200 welcome \$ssl_client_s_dn\n";
}
}
}
EOF
nginx -c $PWD/nginx-authz.conf && sleep 1
R="--resolve api.lab.test:8443:127.0.0.1"; C="--cacert server-ca.crt"
echo "=== /public, no certificate:"; curl -s $C $R https://api.lab.test:8443/public
echo "=== /private, no certificate:"; curl -s $C $R https://api.lab.test:8443/private
echo "=== /private, payments-svc:"; curl -s $C --cert payments-svc.crt --key payments-svc.key $R https://api.lab.test:8443/private
echo "=== /private, reporting-svc:"; curl -s $C --cert reporting-svc.crt --key reporting-svc.key $R https://api.lab.test:8443/private
nginx -s stop -c $PWD/nginx-authz.conf✅ Expected result — click to reveal
=== /public, no certificate:
public: verify=NONE
=== /private, no certificate:
401 no valid client certificate
=== /private, payments-svc:
200 welcome O=Lab Ltd,CN=payments-svc
=== /private, reporting-svc:
403 O=Lab Ltd,CN=reporting-svc is authenticated but not authorisedWhat to read out of this — the last two lines are the entire point of the section.
- 401 and 403 mean different things and you should keep them different. 401 is I do not know who you are. 403 is I know exactly who you are, and no. Collapsing both into one response makes every future support conversation harder.
- reporting-svc had a perfectly valid certificate from a trusted CA and was still refused. That is authorisation working. Without the map, it would have been let in.
- verify=NONE on /public shows what optional does: the check ran, found nothing, recorded NONE, and carried on.
- The map key is "O=Lab Ltd,CN=payments-svc". If you get 403 for a client you meant to allow, print $ssl_client_s_dn and copy the string from there — do not type it from the CSR. This is B2's trap, and it is the single most common reason this pattern does not work first time.
🔑 map is the right tool here and if is not. nginx's if inside location is notoriously surprising, and it is used above only because it keeps the example short. In production, do the decision in map — which is evaluated once, cheaply, and predictably — and use it to select an upstream, a return, or an auth_request. "Use map, avoid if" is a good thing to be able to say about nginx generally.
💡 Now imagine this at 500 hosts. A map with 500 exact DN strings is unmaintainable and will drift. This is the practical argument for the URI-SAN identities from B2: one regex over spiffe://lab.test/ns/payments/* replaces fifty lines and keeps working when a new service is added to that namespace.
🎯 Interview questions — Authorisation
Q. When would you use ssl_verify_client optional instead of on?
When one server block serves both public and protected content, or when you need different rules for different identities rather than one rule for the whole server. optional requests and verifies the certificate but does not reject anyone, recording the outcome in $ssl_client_verify so you can act on it per location.
on is simpler and safer where it fits, because nginx does the enforcement and you cannot forget to.
The detail worth adding: the danger is that optional moves responsibility to you, and any protected path where you forget the check is simply open. The specific bug to name is writing the check as if ($ssl_client_verify = NONE) — with optional_no_ca an untrusted certificate produces FAILED:..., not NONE, so that test passes an attacker straight through. Always test positively for SUCCESS.
Q. How do you do authorisation on top of mTLS?
Extract a stable identity from the certificate — the subject DN, an OU, or ideally a URI SAN — and map it to permissions. In nginx that is a map block keyed on $ssl_client_s_dn feeding a decision; at larger scale it is a policy engine, or a service mesh doing it centrally.
The key design choice is what the identity looks like. Exact DN strings mean one rule per service and constant maintenance; structured URI identities let you write rules over namespaces and environments.
The detail worth adding: say where the decision should live. Enforcing in each application means every team must implement it correctly, which is exactly the problem mTLS was supposed to solve. Enforcing at the proxy or mesh gives you one implementation, one audit point and one place to change — and it is why service meshes ended up owning this. If the interviewer pushes on defence in depth, the honest answer is that critical services should do both, and that most estates do neither consistently.
C4 · Handing the identity to the application — and the bypass that undoes it
The doorman is excellent. He checks every pass, and writes your name on a slip that he hands to the office. The office trusts the slip completely, because only the doorman ever produces one.
Then somebody props open the loading bay at the back. Now anyone can walk in, write their own slip, and hand it to the office. The office has no way to tell the difference.
The doorman is not broken. The building is.
TLS terminates at the proxy. The application behind it speaks plain HTTP and has no idea a certificate was involved, so the proxy must tell it — normally by adding a header:
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Cert $ssl_client_escaped_cert; # if the app needs the whole thingThe application then trusts X-Client-DN. And that trust is only worth anything if the application cannot be reached except through the proxy.
The certificate was the proof, and the proof was consumed at the proxy. Everything after that point is the proxy's word for it.
So two things must both be true: the proxy must set the header unconditionally (so a client-supplied one is always overwritten — proxy_set_header does this for you), and the application must be unreachable by any other route. Network policy, a listener bound to loopback, a firewall — something.
Miss the second and you have built an authentication system with a labelled bypass.
🧪 Exercise C4.1 — Walk in through the back door
cd ~/tls-lab/m12
cat > nginx-proxy.conf <<EOF
worker_processes 1;
error_log $PWD/logs/error-proxy.log warn;
pid $PWD/logs/nginx-proxy.pid;
events { worker_connections 64; }
http {
access_log off;
# --- the application. it trusts the header the proxy sets. ---
server {
listen 127.0.0.1:8081;
location / {
default_type text/plain;
return 200 "app sees identity: \$http_x_client_dn\n";
}
}
# --- the proxy, correctly enforcing mTLS ---
server {
listen 8445 ssl;
ssl_certificate $PWD/server.crt;
ssl_certificate_key $PWD/server.key;
ssl_client_certificate $PWD/client-ca.crt;
ssl_verify_client on;
location / {
proxy_set_header X-Client-DN \$ssl_client_s_dn;
proxy_pass http://127.0.0.1:8081;
}
}
}
EOF
nginx -c $PWD/nginx-proxy.conf && sleep 1
C="--cacert server-ca.crt"
echo "=== 1. through the proxy, with a real certificate:"
curl -s $C --cert payments-svc.crt --key payments-svc.key \
--resolve api.lab.test:8445:127.0.0.1 https://api.lab.test:8445/
echo "=== 2. through the proxy, spoofing the header, no certificate:"
curl -s -o /dev/null -w "HTTP %{http_code} (the proxy rejected it)\n" $C \
-H 'X-Client-DN: O=Lab Ltd,CN=payments-svc' \
--resolve api.lab.test:8445:127.0.0.1 https://api.lab.test:8445/
echo "=== 3. straight to the application, past the proxy entirely:"
curl -s -H 'X-Client-DN: O=Lab Ltd,CN=payments-svc' http://127.0.0.1:8081/
nginx -s stop -c $PWD/nginx-proxy.conf✅ Expected result — click to reveal
=== 1. through the proxy, with a real certificate:
app sees identity: O=Lab Ltd,CN=payments-svc
=== 2. through the proxy, spoofing the header, no certificate:
HTTP 400 (the proxy rejected it)
=== 3. straight to the application, past the proxy entirely:
app sees identity: O=Lab Ltd,CN=payments-svcWhat to read out of this — lines 1 and 3 are identical, and one of them required no certificate at all.
- Case 2 shows the proxy doing its job. You cannot smuggle the header past it: proxy_set_header overwrites whatever the client sent, and in any case the connection is rejected before it gets that far.
- Case 3 is the real vulnerability, and there was no flaw in the TLS anywhere. The mTLS was correct, the certificate check was correct, the header handling was correct. The application was simply reachable another way.
- The application cannot tell the two apart. From its side both requests are identical plain HTTP with the same header. There is nothing it could check.
⚠️ This is the most common real-world way an mTLS deployment is defeated, and it is almost never found by testing the mTLS. It is found by asking a different question: "what else can reach this port?" Container networks, Kubernetes services, cloud load balancers with a second listener, a debug port someone opened in 2024 — all of them produce case 3.
🔑 The two-part rule, worth saying in an interview. Identity headers are only as trustworthy as the network path. Set them unconditionally at the proxy, and make the backend unreachable except through it. If you can only do one, do the second — a backend nobody can reach directly is safe even with sloppy header handling, whereas perfect header handling on a publicly reachable backend protects nothing.
💡 Now imagine this at 500 hosts. Verifying "nothing can reach the backend directly" 500 times, by hand, forever, is not realistic. This is the argument for a service mesh, where mTLS runs all the way to each workload's sidecar rather than terminating at one shared edge — there is no plain-HTTP segment left to sneak into. Part D.
C5 · Revocation that actually works
Module 09 explained why cancelling a credit card barely works on the public internet: the shop has to ask the bank, the bank is slow or unreachable, and the shop is told to let it through rather than annoy a customer. Soft-fail.
Inside your own building none of that applies. There is one doorman, one list, and it is printed and sitting on his desk. He does not phone anyone. If the number is on the list, you do not come in.
Revocation is broken on the public web and works perfectly in an internal PKI, and the reason is simply that you control both ends.
You already built the CRL in B3. Wiring it in is one directive.
| Server | Configuration |
|---|---|
| nginx | ssl_crl /path/to/client-ca.crl; — PEM format. Intermediates' CRLs go in the same file |
| Apache | SSLCARevocationFile plus SSLCARevocationCheck chain. The check defaults to none, so the file alone does nothing |
🧪 Exercise C5.1 — Cancel a service's access and watch it stop working
cd ~/tls-lab/m12
# add the CRL you generated in B3 to the config from C1
sed -i "s|ssl_verify_client on;|ssl_crl $PWD/client-ca.crl;\n ssl_verify_client on;|" nginx.conf
nginx -t -c $PWD/nginx.conf
nginx -c $PWD/nginx.conf && sleep 1
R="--resolve api.lab.test:8443:127.0.0.1"; C="--cacert server-ca.crt"
echo "=== payments-svc (not revoked):"
curl -s -o /dev/null -w "HTTP %{http_code}\n" $C --cert payments-svc.crt --key payments-svc.key $R https://api.lab.test:8443/
echo "=== reporting-svc (revoked in B3):"
curl -s -o /dev/null -w "HTTP %{http_code}\n" $C --cert reporting-svc.crt --key reporting-svc.key $R https://api.lab.test:8443/
grep -o "client SSL certificate verify error: ([^)]*)" logs/error.log | tail -1
nginx -s stop -c $PWD/nginx.conf✅ Expected result — click to reveal
=== payments-svc (not revoked):
HTTP 200
=== reporting-svc (revoked in B3):
HTTP 400
client SSL certificate verify error: (23:certificate revoked)What to read out of this — compare it with Module 09 and notice how much simpler this is.
- Error 23, certificate revoked. Another entry for the Module 07 table. Note it is a verification error like any other — revocation is just one more gate in the same path validation.
- It took effect immediately. No OCSP responder, no soft-fail, no waiting for a cache to expire. nginx reads the CRL file and applies it.
- reporting-svc's certificate is still cryptographically perfect. Nothing about the file changed. The only thing that changed is a line in your CA's ledger and a serial number in a file on the server.
⚠️ The failure mode is the opposite of the public web's, and it is worse. Public revocation fails open — nobody notices. Internal CRL checking fails closed: nginx will not start, or will reject every client, if the CRL file is missing, malformed, or past its Next Update. Module 09's warning applies with full force — monitor Next Update, not whether the file exists. A CRL that expires at 3am takes down every client at once.
🔑 nginx reads the CRL at load time, not per request. Regenerating the file changes nothing until you reload nginx (nginx -s reload). Any revocation runbook must include the reload, on every server, or the revocation is decorative.
💡 Now imagine this at 500 hosts. Distributing a CRL file to 500 servers and reloading each of them, within your promised revocation window, is a real engineering problem. This is precisely why modern internal PKI leans on short lifetimes instead of revocation — a one-hour certificate does not need a CRL, because it is gone before you could have distributed one. That is the central idea of Part D.
🎯 Interview questions — Internal revocation
Q. Module 09 said revocation is broken. Why does it work here?
Because the reasons it fails publicly do not apply. On the web, the client is a browser you do not control, checking with a CA you do not control, over a network that may not reach it — so browsers soft-fail and ignore the answer. Internally, you own the CA, you own every server doing the checking, and the CRL is a local file. There is no network call to fail, so there is nothing to soft-fail on.
In nginx it is one directive, ssl_crl, and a revoked client is refused with verify error 23.
The detail worth adding: flip the risk around, because that is the part people miss. Internal CRL checking fails closed — a missing, malformed or expired CRL rejects every client simultaneously. Public revocation risks letting a bad certificate in; internal revocation risks a total outage. So the thing to monitor is the CRL's Next Update, and the thing to automate is regenerating and redistributing it well before then.
Q. A service's private key has leaked. What do you do?
Issue a replacement certificate with a brand-new key and deploy it first, so the service keeps working. Then revoke the compromised certificate in the CA, regenerate the CRL, distribute it to every server that checks, and reload each one — a revocation that has not reached the servers has not happened. Then find out how the key leaked, because that is the actual incident.
Rotate in that order: replace, then revoke. Revoking first takes the service down.
The detail worth adding: the answer that shows range is to ask how long the certificate had left to live. If your PKI issues one-hour certificates, the honest response may be to do nothing but rotate — the leaked credential expires before a CRL could realistically be distributed to the fleet, and skipping the revocation dance is faster and less risky than performing it. Recognising when revocation is not worth doing is a more senior answer than reciting the procedure.
Part D · Internal PKI at scale
D1 · Why doing it by hand stops working
Tinned food lasts years. You buy it, put it in the cupboard, forget it. That was a one-year certificate.
Bread lasts a day. It is much better bread — but you cannot "remember to buy bread" as a task. You need a delivery.
Short-lived certificates are bread. They are safer for exactly the reason they are inconvenient: a stolen one is worthless almost immediately. But you cannot operate them by remembering. The delivery has to exist first, and then the short lifetime becomes free.
Everything up to here works. It works for ten services and a person who remembers. Here is where it stops.
| What breaks | Why |
|---|---|
| The ledger | index.txt is a flat file with no locking. Two issuances at the same instant corrupt it |
| The CA key | Sits on a disk, protected by whatever permissions someone set once. Nothing rotates it, nothing audits its use |
| Renewal | Somebody must remember. Nobody remembers 500 things |
| Distribution | Getting the new certificate onto the machine, and reloading the service, is a separate manual step every time |
| Revocation | Regenerate the CRL, copy it to every server, reload every server — inside the window you promised |
| Audit | "Who issued this and why?" has no answer beyond a line in a text file |
🧪 Exercise D1.1 — Work out how much renewal work you are signing up for
cd ~/tls-lab/m12
cat > renewal-load.py <<'EOF'
#!/usr/bin/env python3
# How much renewal work does an internal PKI actually create?
def load(services, lifetime_hours, renew_at=0.5):
per_service_per_day = 24.0 / (lifetime_hours * renew_at)
return per_service_per_day, services * per_service_per_day
print(f"{'lifetime':>12} {'per service/day':>16} {'fleet of 500/day':>18} {'per minute':>12}")
for label, hours in [("1 year", 8760), ("90 days", 2160), ("30 days", 720),
("24 hours", 24), ("1 hour", 1)]:
ps, tot = load(500, hours)
print(f"{label:>12} {ps:>16.3f} {tot:>18.0f} {tot/1440:>12.2f}")
EOF
python3 renewal-load.py✅ Expected result — click to reveal
lifetime per service/day fleet of 500/day per minute
1 year 0.005 3 0.00
90 days 0.022 11 0.01
30 days 0.067 33 0.02
24 hours 2.000 1000 0.69
1 hour 48.000 24000 16.67What to read out of this — find the row where the number stops being a task and becomes a rate.
- At 1 year, a 500-service fleet renews about 3 times a day. A person could genuinely do that. Badly, and with the occasional outage, but they could.
- At 30 days it is 33 a day. Now it is somebody's job, and that person will make mistakes.
- At 1 hour it is 17 per minute, forever. There is no version of this that a human touches. The renewal path must be as reliable as the service itself, because it is the service.
- The middle rows are the dangerous ones. A 30-day certificate is short enough to renew constantly and long enough that people convince themselves a calendar reminder will do. That is where outages live.
🔑 The insight to carry into the rest of Part D: automation is not an optimisation, it is a precondition. You do not shorten lifetimes and then automate. You build the issuing service first, and the short lifetime becomes something you can simply switch on. Every tool in this part is built that way round — they all default to short lifetimes because they assume nothing manual is involved.
💡 The same reasoning drove the public web. Module 10 covered the CA/Browser Forum lifetime schedule dropping to 47 days by 2029, and Module 11 covered the Chrome Root Program requiring every subordinate CA to be integrated with automation by 15 March 2027. Internal and public PKI reached the identical conclusion independently: short lifetimes plus automation beats long lifetimes plus revocation.
D2 · cert-manager — certificates as Kubernetes objects
You do not phone the dairy every morning. You leave a note once: "a pint on this doorstep, daily, until I say otherwise." Then it simply arrives, and if it stops arriving that is an incident rather than a forgotten errand.
cert-manager is that note. You write down what certificate you want and where it should live, and a controller keeps reality matching it — issuing, renewing and replacing without anyone asking again.
cert-manager is the standard way to do this in Kubernetes. Three objects:
| Object | What it is |
|---|---|
| Issuer | Where certificates come from — your internal CA, Vault, an ACME server. Namespace-scoped |
| ClusterIssuer | The same, usable from every namespace. What you normally want for an internal CA |
| Certificate | A request that stays true: "this Secret should always contain a valid certificate for these names." |
The controller creates a Kubernetes Secret containing tls.crt, tls.key and usually ca.crt, and the workload mounts it. When renewal is due, the Secret's contents change underneath the pod.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: payments-svc-mtls
namespace: payments
spec:
secretName: payments-svc-mtls # the Secret to keep filled
issuerRef:
name: internal-ca
kind: ClusterIssuer
duration: 24h # short, because nothing manual is involved
renewBefore: 8h # renew with a third of the life left
usages:
- client auth # the EKU from B1
- server auth
uris:
- spiffe://lab.test/ns/payments/sa/checkout # the identity from B2
privateKey:
rotationPolicy: Always # a NEW key each renewal, not just a new certificateprivateKey.rotationPolicy defaults to Never, which reuses the same private key on every renewal. That means a key stolen once stays valid forever, no matter how short you make the certificates — you have all the operational cost of short lifetimes and none of the benefit. Set it to Always.
renewBefore must be comfortably less than duration, and further out than your worst-case deployment time. With duration: 24h and renewBefore: 1h, a controller outage of ninety minutes expires your whole cluster.
cert-manager updates the Secret. Kubernetes updates the mounted file, eventually — a projected volume refresh can take a minute or more. Your process is still holding the old certificate in memory, because most TLS libraries read the file once at startup.
So the certificate on disk is fresh and the one being served is expired. Either watch the file and reload (nginx and Envoy can), or accept that renewal means a rolling restart. Deciding which, deliberately, is part of adopting this — not something to discover at 3am.
🧪 Exercise D2.1 — Produce the same certificate cert-manager would, using only OpenSSL
cd ~/tls-lab/m12
# this is exactly what the Certificate above asks for
cat > cm-equivalent.cnf <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=clientAuth,serverAuth
subjectAltName=critical,URI:spiffe://lab.test/ns/payments/sa/checkout
EOF
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out cm.key # rotationPolicy: Always
openssl req -new -key cm.key -out cm.csr -subj "/"
openssl x509 -req -in cm.csr -CA client-ca.crt -CAkey client-ca.key -CAcreateserial \
-days 1 -sha256 -extfile cm-equivalent.cnf -out cm.crt # duration: 24h
echo "=== the three files a cert-manager Secret would hold:"
ls -1 cm.key cm.crt client-ca.crt | sed 's/cm.key/tls.key <- cm.key/; s/cm.crt/tls.crt <- cm.crt/; s/client-ca.crt/ca.crt <- client-ca.crt/'
echo "=== and what is inside tls.crt:"
openssl x509 -in cm.crt -noout -dates -ext subjectAltName,extendedKeyUsage✅ Expected result — click to reveal
=== the three files a cert-manager Secret would hold:
tls.key <- cm.key
tls.crt <- cm.crt
ca.crt <- client-ca.crt
=== and what is inside tls.crt:
notBefore=Aug 26 13:40:11 2026 GMT
notAfter=Aug 27 13:40:11 2026 GMT
X509v3 Subject Alternative Name: critical
URI:spiffe://lab.test/ns/payments/sa/checkout
X509v3 Extended Key Usage:
TLS Web Client Authentication, TLS Web Server AuthenticationWhat to read out of this — there is no magic in cert-manager. It runs these commands for you, on a loop.
- The Secret is three files with fixed names: tls.crt, tls.key, ca.crt. Anything that can read a certificate from disk can consume it; nothing needs to know about cert-manager.
- duration: 24h is -days 1. usages is the EKU from B1. uris is the URI SAN from B2. Every field maps onto something you have already built by hand.
- A new key was generated, matching rotationPolicy: Always. Reusing cm.key on the next issuance would be the default behaviour you do not want.
🔑 This is the mental model worth keeping for every tool in Part D. cert-manager, Vault and SPIRE are not doing anything you have not now done manually. They are a control loop around it: keep this thing true, notice when it stops being true, fix it. The value is entirely in the loop, and that is exactly the part you cannot build with a cron job and hope.
💡 If you have a cluster to hand, the real version is helm install cert-manager jetstack/cert-manager --set crds.enabled=true, then a ClusterIssuer of kind: CA pointing at a Secret holding client-ca.crt and client-ca.key. Do not do that on a work laptop's cluster without asking — it installs cluster-wide CRDs and a controller with broad permissions.
🎯 Interview questions — Automating issuance
Q. How do you manage certificates in Kubernetes?
With cert-manager. You define a ClusterIssuer describing where certificates come from — an internal CA, Vault, or an ACME server — and a Certificate resource declaring what should exist. A controller reconciles that continuously, writing tls.crt, tls.key and ca.crt into a Secret and renewing before expiry without anyone asking.
It is declarative in the Kubernetes sense: you state the desired end state and the controller keeps reality matching it, including after a failure.
The detail worth adding: name two fields, because they are where real incidents come from. privateKey.rotationPolicy defaults to Never, so renewals reuse the same key — which quietly removes most of the benefit of short lifetimes, since a stolen key stays valid indefinitely. And renewBefore must exceed your worst-case deployment or outage window; a 24-hour certificate with a one-hour renewBefore gives you one hour of slack for the entire cluster.
Q. cert-manager renewed the certificate but the service is still serving the old one. What happened?
The Secret was updated and the process did not reload. Most TLS libraries read the certificate file once at startup and hold it in memory, so a new file on disk changes nothing. There is also a delay before Kubernetes refreshes a projected volume — often a minute or more — so even the file is not instantly current.
The fix is either a server that watches and reloads (nginx and Envoy do), a sidecar that signals it, or accepting that renewal means a rolling restart.
The detail worth adding: the reason this bites so hard with short lifetimes is that the gap between "renewed" and "actually serving" used to be irrelevant. With a one-year certificate, a restart happens naturally long before expiry. With a 24-hour certificate the reload path has to be deliberate and tested — and it is the single most common reason a first cert-manager rollout causes an outage rather than preventing one.
D3 · Vault PKI — certificates on request, with rules
Anyone with a staff ID can get a key cut. But the counter has rules pinned up behind it: the maintenance team may cut keys for the basement, and for nothing above the ground floor, and those keys last one shift.
The person at the counter does not exercise judgement. They read the rule for your team and cut exactly what it allows.
A Vault PKI role is that pinned-up rule. It is written once, by whoever owns the CA, and everything issued afterwards is constrained by it.
Vault's PKI engine turns your CA into an API. Two ideas make it different from a script that runs openssl ca:
Roles constrain what may be issued. A role fixes the allowed names, the key type, the EKUs and — most importantly — the maximum TTL. A caller can ask for less; they cannot ask for more. The policy lives with the CA rather than with whoever is calling it.
Vault's own auth decides who may ask. There is no CSR approval queue and no human in the loop. A workload authenticates to Vault however it already does — Kubernetes service account, AppRole, cloud IAM — and its Vault policy determines which PKI roles it can reach.
# enable the engine and set a hard ceiling on certificate lifetime
vault secrets enable -path=pki-int -max-lease-ttl=8760h pki
# a role: what payments services are allowed to ask for
vault write pki-int/roles/payments-svc \
allowed_domains="payments.svc.lab.test" \
allow_subdomains=true \
allowed_uri_sans="spiffe://lab.test/ns/payments/*" \
client_flag=true server_flag=true \
key_type=ec key_bits=256 \
max_ttl=24h ttl=24h
# a workload asks for a certificate; Vault generates the key and returns everything
vault write pki-int/issue/payments-svc \
common_name="checkout.payments.svc.lab.test" ttl=1hVault can generate the private key itself and hand it back over the API, so the certificate and key need never touch a disk at all. The workload holds them in memory, uses them, and they vanish when the process stops.
That closes the gap left over from A1's interview answer. mTLS beats an API key because the secret is not transmitted — but a key sitting in a file is still a file someone can read. A key that only ever exists in memory, for one hour, is a materially different thing.
It also removes most of the reason to revoke. Vault's own documentation makes the point directly: keep TTLs short and "revocations are less likely to be needed, keeping CRLs short."
A role asking for max_ttl=24h under a mount enabled with -max-lease-ttl=1h silently gets one hour. The certificate arrives, it is valid, and it expires 23 hours before anything expected it to. The error appears nowhere; the lifetime is just quietly shorter than you asked for.
Always check the notAfter of the first certificate a new role issues rather than trusting the role definition.
🧪 Exercise D3.1 — Build the same constraint with OpenSSL, and watch it bite
Vault is not installed, and you should not install it on a work laptop just for this. The mechanism a role implements is a ceiling, and you can build one in four lines:
cd ~/tls-lab/m12
# a "role": nothing may be issued for longer than 24 hours
issue() {
local name="$1" want_hours="$2" max_hours=24
local hours=$(( want_hours < max_hours ? want_hours : max_hours ))
[ "$want_hours" -gt "$max_hours" ] && echo " (asked for ${want_hours}h, role ceiling is ${max_hours}h)"
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out $name.key 2>/dev/null
openssl req -new -key $name.key -out $name.csr -subj "/CN=$name" 2>/dev/null
openssl ca -batch -config ca.cnf -extensions cli_ext -in $name.csr -out $name.crt \
-startdate $(date -u +%Y%m%d%H%M%SZ) \
-enddate $(date -u -d "+${hours} hours" +%Y%m%d%H%M%SZ) 2>/dev/null
echo " $name -> $(openssl x509 -in $name.crt -noout -enddate)"
}
echo "=== a well-behaved request:"; issue short-svc 1
echo "=== a request that exceeds the role's ceiling:"; issue greedy-svc 720✅ Expected result — click to reveal
=== a well-behaved request:
short-svc -> notAfter=Aug 26 14:34:32 2026 GMT
=== a request that exceeds the role's ceiling:
(asked for 720h, role ceiling is 24h)
greedy-svc -> notAfter=Aug 27 13:34:32 2026 GMT
# for reference, the clock at the time of the run:
Wed Aug 26 13:34:32 UTC 2026What to read out of this — the greedy request succeeded. It just did not get what it asked for.
- Nothing failed. The caller asked for 30 days and received a valid certificate for 24 hours. That is exactly how Vault behaves, and it is why the trap above matters: a silently shortened lifetime looks like success.
- -startdate and -enddate are how you set an exact validity window with openssl ca. Module 07 introduced them when openssl x509 -req turned out not to support -not_before on OpenSSL 3.0. Same technique, different use.
- The policy lives with the issuer, not the caller. The issue function decides; the caller only asks. That single inversion is most of what a PKI service gives you over a shared script.
🔑 The habit this should leave you with: verify the certificate you got, not the request you sent. Check notAfter on the first certificate from any new role, issuer or automation. Every PKI service in this part will quietly give you something smaller than you asked for under some configuration, and none of them will tell you.
💡 Now imagine this at 500 hosts. The ceiling is what stops one team's ttl=8760h from creating 500 year-long credentials nobody tracks. Without it, the first person who finds renewals annoying will simply ask for a longer certificate, and your short-lifetime design quietly evaporates over about six months.
🎯 Interview questions — Vault and issuing services
Q. What does Vault's PKI engine give you over a script that runs openssl ca?
Policy and identity. A role constrains what can be issued — allowed domains and URI SANs, key type, EKUs, and a maximum TTL — so the rules live with the CA rather than with each caller. And Vault's own authentication decides who may reach which role, so a workload gets a certificate by proving it is that workload, using whatever identity it already has: a Kubernetes service account, AppRole, or cloud IAM.
It also handles the CA key properly: audited access, a real storage backend, and no key file sitting on a build server.
The detail worth adding: the capability worth naming is ephemeral certificates. Vault can generate the private key and return it over the API, so the key need never touch disk — the workload holds it in memory for an hour and it is gone. That closes the last gap in the mTLS story, because a private key in a file is still a file someone can steal. Vault's docs also make the operational point directly: short TTLs mean revocation is rarely needed, which keeps CRLs small.
Q. Your certificates are expiring far sooner than the role says. Where do you look?
At the mount's max_lease_ttl, set when the PKI engine was enabled. It is a hard ceiling above every role beneath it, so a role with max_ttl=24h under a mount enabled with -max-lease-ttl=1h issues one-hour certificates and reports no error at all.
Confirm it by reading notAfter on an issued certificate rather than trusting the role definition.
The detail worth adding: generalise it, because the same class of bug appears everywhere in PKI. Requested lifetimes are ceilings negotiated at several layers — the CA's own expiry caps everything beneath it, an intermediate caps its leaves, a mount caps its roles, a role caps its callers, and public CAs cap by policy. When a lifetime is not what you asked for, work down that stack. And the reason it goes unnoticed is that every layer silently truncates rather than refusing.
D4 · SPIFFE and SPIRE — identity a workload never has to be given
To get a staff pass you must show ID. To get ID you must show a birth certificate. To get a birth certificate somebody must have vouched for you at birth, when you had nothing at all.
Every credential system has this problem at the bottom. To get a certificate you must authenticate. To authenticate you need a credential. Where does the very first one come from?
Most systems answer badly: they bake a secret into the image, or drop one on the disk at deploy time, and that secret is long-lived and copyable. SPIFFE answers differently. It has an agent on the node look at the workload — its process, its container, its Kubernetes service account, the node's cloud identity — and vouch for it based on what it observably is. The workload is never given a secret to start with.
Three words, and they are the whole model:
| Term | What it means |
|---|---|
| SPIFFE ID | A name, written as a URI: spiffe://lab.test/ns/payments/sa/checkout. lab.test is the trust domain, the rest identifies the workload |
| SVID | The document proving the ID. An X509-SVID is an ordinary certificate carrying the SPIFFE ID in a URI SAN |
| Workload API | A local socket a workload reads to receive its SVID, its private key and the trust bundle. No configuration, no secret, no file to protect |
SPIRE is the usual implementation: a server holding the CA and the registration rules, and an agent on each node that attests workloads and hands out SVIDs through that socket. Certificates typically live an hour or less and are rotated continuously without the workload doing anything.
The X509-SVID specification is strict about the certificate's shape, and every rule has a reason:
| Rule | Why |
|---|---|
| Exactly one URI SAN | One certificate, one identity. Two would make "who is this?" ambiguous, and a validator must reject it |
| basicConstraints CA:FALSE | A workload identity must never be able to issue further identities |
| keyUsage critical, with digitalSignature | Needed for the CertificateVerify signature from A2. keyCertSign is forbidden on a leaf |
| extendedKeyUsage with both serverAuth and clientAuth | A workload both calls and is called, as B2 showed |
| Subject may be empty — and then the SAN must be critical | If the identity lives only in the SAN, a client that ignores the SAN would see a certificate identifying nobody |
🧪 Exercise D4.1 — Write a checker for the SVID rules, and run it against both your certificates
cd ~/tls-lab/m12
cat > svid-check.sh <<'EOF'
#!/usr/bin/env bash
# svid-check — does this certificate satisfy the SPIFFE X509-SVID rules?
c="$1"; [ -z "$c" ] && { echo "usage: $0 <cert.pem>"; exit 2; }
san=$(openssl x509 -in "$c" -noout -ext subjectAltName 2>/dev/null)
uris=$(echo "$san" | grep -o 'URI:[^,]*' | wc -l)
id=$(echo "$san" | sed -n 's/.*URI:\(spiffe:[^,]*\).*/\1/p')
echo "== $c"
[ "$uris" -eq 1 ] && echo " exactly one URI SAN : ok" || echo " exactly one URI SAN : FAIL ($uris found)"
[ -n "$id" ] && echo " SPIFFE ID : $id" || echo " SPIFFE ID : FAIL (no spiffe:// URI)"
openssl x509 -in "$c" -noout -ext basicConstraints | grep -q "CA:FALSE" \
&& echo " basicConstraints CA:FALSE: ok" || echo " basicConstraints CA:FALSE: FAIL"
ku=$(openssl x509 -in "$c" -noout -ext keyUsage 2>/dev/null | tail -n +2 | xargs)
case "$ku" in
*"Digital Signature"*) echo " keyUsage digitalSignature: ok" ;;
*) echo " keyUsage digitalSignature: FAIL ($ku)" ;;
esac
case "$ku" in *"Certificate Sign"*) echo " keyUsage keyCertSign : FAIL - a leaf must not sign";; esac
eku=$(openssl x509 -in "$c" -noout -ext extendedKeyUsage 2>/dev/null | tail -n +2 | xargs)
case "$eku" in
*"Client Authentication"*) echo " EKU clientAuth : ok" ;;
*) echo " EKU clientAuth : FAIL ($eku)" ;;
esac
sub=$(openssl x509 -in "$c" -noout -subject | cut -d= -f2- | xargs)
if [ -z "$sub" ]; then
echo "$san" | grep -q "critical" \
&& echo " empty Subject + critical SAN: ok" \
|| echo " empty Subject + critical SAN: FAIL - SAN must be critical when Subject is empty"
else
echo " Subject : $sub (allowed, but the SPIFFE ID is what counts)"
fi
EOF
chmod +x svid-check.sh
./svid-check.sh svid.crt
echo
./svid-check.sh payments-svc.crt✅ Expected result — click to reveal
== svid.crt
exactly one URI SAN : ok
SPIFFE ID : spiffe://lab.test/ns/payments/sa/checkout
basicConstraints CA:FALSE: ok
keyUsage digitalSignature: ok
EKU clientAuth : ok
empty Subject + critical SAN: ok
== payments-svc.crt
exactly one URI SAN : FAIL (0 found)
SPIFFE ID : FAIL (no spiffe:// URI)
basicConstraints CA:FALSE: ok
keyUsage digitalSignature: ok
EKU clientAuth : ok
Subject : CN = payments-svc, O = Lab Ltd (allowed, but the SPIFFE ID is what counts)What to read out of this — payments-svc.crt is a perfectly good client certificate that is not an SVID, and the difference is only where the identity lives.
- Everything about payments-svc.crt is valid. It works in every exercise in this module. It simply carries its name in a DN instead of a URI SAN, so it cannot be used in a SPIFFE trust domain.
- svid.crt has an empty Subject and a critical SAN. Those two go together: if the only identity is in the SAN, nothing may be allowed to ignore the SAN.
- The checker is about 25 lines of shell. That is worth noticing — an SVID is not a new certificate format. It is an ordinary X.509 certificate with a naming convention and a handful of rules.
🔑 The idea to take away even if you never deploy SPIRE: identity should be derived from what a workload is, not given to it as a secret. Any scheme where the credential is baked into an image, injected at deploy time, or stored in a file the workload reads has a long-lived secret at the bottom of it. Attestation replaces that with a fresh observation each time, which is why SVIDs can safely last an hour.
💡 Now imagine this at 500 hosts. Structured identities are what makes 500 tractable. spiffe://lab.test/ns/payments/* is one authorisation rule covering every service in a namespace, present and future. The DN equivalent is 500 exact strings that must each be maintained by hand — the same point C3 reached from the other direction.
🎯 Interview questions — Workload identity
Q. What is SPIFFE, and what problem does it solve?
SPIFFE is a standard for workload identity. A workload gets a SPIFFE ID — a URI like spiffe://trust-domain/path — and proves it with an SVID, which for X.509 is an ordinary certificate carrying that ID in a URI SAN. SPIRE is the common implementation: a server holding the CA, and an agent on each node that hands out SVIDs over a local socket.
The problem it solves is bootstrapping. Normally a workload needs a credential to obtain a credential, and teams break the loop by baking a long-lived secret into an image or dropping one on disk.
The detail worth adding: explain attestation, because it is the actual idea. The SPIRE agent identifies a workload by observing what it demonstrably is — its process, container, Kubernetes service account, the node's cloud identity — and issues on that basis. The workload never holds a secret it was given; it receives a fresh, short-lived one through a socket. That is what makes one-hour certificates practical, and it means there is no long-lived credential at the bottom of the stack to steal.
Q. Why does an X509-SVID have to contain exactly one URI SAN?
So that "who is this?" has exactly one answer. A certificate with two SPIFFE IDs would let a verifier and an authoriser reach different conclusions about the same connection, which is a security hole rather than a flexibility feature. The specification requires validators to reject certificates with more than one URI SAN.
The related rule is that if the Subject is empty — which is normal for SVIDs — the SAN must be marked critical, so nothing can accept a certificate while ignoring the only field that identifies it.
The detail worth adding: contrast it with a server certificate, where multiple SANs are entirely normal because a server legitimately answers to many names. The asymmetry is the point: a server is a set of names, a workload is one identity. Noticing that the two ends of a connection have genuinely different naming needs is a good sign you understand what SANs are actually for.
D5 · When to run your own CA — and how to make it safe if you do
Running your own CA is being handed a key that opens every door in the building, and being told to look after it. Not for a week — for a decade, through staff changes, reorganisations and at least one office move.
Most teams should not want that job. The ones that should are the ones who cannot buy their way out of it, and they should cut the key so it only opens their own floor. That is a name constraint, and it is the single best thing you can do to a CA you are stuck owning.
The decision is simpler than it is usually made to sound.
| Situation | What to do |
|---|---|
| Anything on the public internet | Public CA, via ACME. Never your own. Module 10 covers it, browsers already trust it, and it is free |
| Internal service-to-service, in Kubernetes | cert-manager, or a service mesh that runs SPIRE for you. Do not hand-roll |
| Internal, mixed estate, you already run Vault | Vault PKI. The CA key gets real protection and you get roles and audit for free |
| Internal, and your cloud provider offers a private CA | Use it. You are paying for someone else to hold the key safely, which is the expensive part |
| Air-gapped, regulated, or genuinely nothing else fits | Run your own — and read the rest of this section carefully |
| "It is only for the test environment" | The most dangerous answer. Test CAs get trusted in production about eighteen months later |
If you must run one, four things make it survivable.
Keep the root offline. Module 05's two-tier design: a root that signs one intermediate and then goes into a safe, and an issuing CA that does the daily work. If the issuing CA is compromised you replace it; if the root is compromised you replace everything, everywhere.
Constrain it by name. This is the one most people skip and it is the most valuable.
Never install it in a trust store more widely than it needs. A CA trusted only by the six servers that need it has a blast radius of six servers.
Write down how you would replace it, and check the note is still true once a year. A CA nobody knows how to rotate is a CA that will one day expire in production.
Install Lab Client CA in your laptops' trust stores, and whoever holds that key can issue a valid certificate for your bank, and those laptops will accept it.
A nameConstraints extension limits a CA to a set of names — .lab.test and nothing else. Certificates outside it fail with verify error 47, permitted subtree violation, on every client that checks.
It costs one line at CA creation and cannot be added later without reissuing the CA. Do it on the day you create it.
🧪 Exercise D5.1 — Build a CA that physically cannot issue for a name you do not owngoogle.com
cd ~/tls-lab/m12
cat > nc.cnf <<'EOF'
basicConstraints=critical,CA:TRUE,pathlen:0
keyUsage=critical,keyCertSign,cRLSign
nameConstraints=critical,permitted;DNS:.lab.test,permitted;URI:.lab.test
EOF
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out nc-ca.key
openssl req -new -key nc-ca.key -out nc-ca.csr -subj "/CN=Constrained Internal CA"
openssl x509 -req -in nc-ca.csr -CA client-ca.crt -CAkey client-ca.key -CAcreateserial \
-days 1825 -sha256 -extfile nc.cnf -out nc-ca.crt
openssl x509 -in nc-ca.crt -noout -ext nameConstraints
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out ok.key
cat > ok.cnf <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=clientAuth
subjectAltName=DNS:svc.lab.test
EOF
echo "--- inside the constraint:"
openssl req -new -key ok.key -out ok.csr -subj "/CN=svc.lab.test"
openssl x509 -req -in ok.csr -CA nc-ca.crt -CAkey nc-ca.key -CAcreateserial \
-days 30 -sha256 -extfile ok.cnf -out ok.crt
openssl verify -CAfile client-ca.crt -untrusted nc-ca.crt ok.crt
echo "--- outside the constraint:"
sed 's/svc.lab.test/www.google.com/' ok.cnf > bad.cnf
openssl req -new -key ok.key -out bad.csr -subj "/CN=www.google.com"
openssl x509 -req -in bad.csr -CA nc-ca.crt -CAkey nc-ca.key -CAcreateserial \
-days 30 -sha256 -extfile bad.cnf -out bad.crt
openssl verify -CAfile client-ca.crt -untrusted nc-ca.crt bad.crt✅ Expected result — click to reveal
X509v3 Name Constraints: critical
Permitted:
DNS:.lab.test
URI:.lab.test
--- inside the constraint:
ok.crt: OK
--- outside the constraint:
CN = www.google.com
error 47 at 0 depth lookup: permitted subtree violation
error bad.crt: verification failedWhat to read out of this — the CA happily signed the google.com certificate. It is the verifier that refuses it.
- error 47 — permitted subtree violation. One more for the Module 07 table. The certificate is correctly signed by a CA in the chain, and it is still rejected, because the CA was not permitted to sign that name.
- Signing succeeded and verification failed. That distinction is worth holding on to: name constraints are enforced by whoever checks the chain, not by the CA. So the protection works even if your CA software is compromised, as long as the constraint is baked into the CA certificate — which is exactly why it must be set at creation and cannot be added afterwards.
- DNS:.lab.test with a leading dot means "this domain and everything under it". svc.lab.test matches; lab.test.evil.com does not.
- pathlen:0 stops this CA from creating further CAs, which would otherwise be a way around the constraint on some clients.
🔑 This is the highest-value line in the whole module, per character typed. One nameConstraints line turns a CA that could impersonate the entire internet into one that can only impersonate you. If you take a single practical action away from Module 12, make it adding name constraints to every internal CA you create from now on.
⚠️ Support is good but not universal. OpenSSL, Firefox, Chrome, Go and macOS enforce name constraints. Some older embedded stacks and a few Java versions historically did not. Treat it as a strong extra layer rather than your only defence — but there is no reason not to have it.
💡 Now imagine this at 500 hosts. The constraint is set once, on one CA certificate, and every one of the 500 clients enforces it independently with no configuration. That is a rare shape in security: a control you apply in one place that is checked everywhere, and that keeps working even if the thing you are protecting against is the CA itself.
🎯 Interview questions — Owning a CA
Q. When should a team run its own CA?
For internal traffic only, and only when nothing else fits. Anything facing the public internet should use a public CA via ACME. Inside Kubernetes, cert-manager or a service mesh should own it. If you already run Vault or your cloud provider offers a private CA, use those — you are paying them to hold the CA key safely, which is the genuinely hard part.
Running your own makes sense when you are air-gapped, under a regulatory constraint, or need identities that no public CA will issue — SPIFFE IDs, for example.
The detail worth adding: the cost people underestimate is not issuance, it is custody over time. The key must stay safe for a decade, through staff turnover and at least one migration, and somebody must know how to rotate it. Saying "I would want a written, tested rotation procedure before I created the CA, not after" is the answer of somebody who has watched a CA expire in production.
Q. What is a name constraint, and why does it matter for an internal CA?
A nameConstraints extension on a CA certificate limits the names that CA may issue for — for example permitted;DNS:.lab.test. Any certificate outside that set fails validation with error 47, permitted subtree violation, on every client that enforces it.
It matters because an internal CA installed in your machines' trust stores can otherwise issue a valid certificate for any domain on earth, including your bank, and those machines will accept it. A constraint reduces that from "the whole internet" to "our own names".
The detail worth adding: the property that makes it powerful is that it is enforced by the verifier, not the issuer. The CA will happily sign a certificate for google.com; every client will reject it. So the protection survives the CA itself being compromised — which is the threat you actually care about. The catch is that it must be set when the CA certificate is created and cannot be added later without reissuing the CA and everything under it, so it is a day-one decision.
Part E · Putting it together
E1 · How this all fits — the complete picture
Diagram source
flowchart TD
W["🧩 Workload starts<br>holding no secret at all"]
ATT{"👁️ Attestation<br>What is this process<br>observably running as?"}
ISS["🏭 Issuing CA<br>cert-manager · Vault · SPIRE<br>role and ceiling applied"]
NC{"🚧 Name constraints<br>Is this name permitted?"}
SVID["📜 Short-lived certificate<br>URI SAN identity<br>clientAuth + serverAuth"]
CONN["🤝 TLS handshake<br>CertificateRequest<br>Certificate + CertificateVerify"]
AUTHN{"🔍 Authentication<br>Chain · dates · EKU · CRL"}
AUTHZ{"📋 Authorisation<br>Is THIS identity<br>allowed HERE?"}
APP["✅ Request reaches<br>the application"]
R1["🛑 Rejected in the handshake<br>certificate required · unknown ca<br>error 21 · 23 · 26"]
R2["🛑 403 Forbidden<br>known, and not permitted"]
R3["🛑 error 47<br>permitted subtree violation"]
ROT["🔄 Renew before expiry<br>NEW key each time"]
BYPASS["⚠️ Direct route to the backend<br>identity header spoofed"]
W --> ATT
ATT -->|"recognised"| ISS
ISS --> NC
NC -->|"no"| R3
NC -->|"yes"| SVID
SVID --> CONN
CONN --> AUTHN
AUTHN -->|"fails"| R1
AUTHN -->|"passes"| AUTHZ
AUTHZ -->|"no"| R2
AUTHZ -->|"yes"| APP
SVID -.->|"hours, not months"| ROT
ROT -.-> SVID
BYPASS -.->|"skips everything above"| APP
style ATT fill:#e1d5e7,stroke:#9673a6,stroke-width:3px
style AUTHN fill:#e1d5e7,stroke:#9673a6,stroke-width:3px
style AUTHZ fill:#e1d5e7,stroke:#9673a6,stroke-width:3px
style R1 fill:#ffcccc,stroke:#cc0000,stroke-width:2px
style R2 fill:#ffcccc,stroke:#cc0000,stroke-width:2px
style R3 fill:#ffcccc,stroke:#cc0000,stroke-width:2px
style BYPASS fill:#ffcccc,stroke:#cc0000,stroke-width:3px
style APP fill:#d5e8d4,stroke:#82b366,stroke-width:2px
style ROT fill:#fff2cc,stroke:#d6b656,stroke-width:2pxRead it as three purple gates and one red arrow. The gates are the three questions this module kept separating: what is this thing? (attestation), is this certificate genuine? (authentication), and is this identity allowed here? (authorisation). Teams routinely build the middle one and assume they have all three.
The red dotted arrow is the point of the diagram. It bypasses every gate, and nothing in the certificate world can stop it, because it never touches TLS at all. C4 built it in fifteen lines of nginx. Whenever you review an mTLS deployment, look for that arrow first and the certificates second.
The yellow loop is what makes the whole thing affordable. Certificates measured in hours mean a stolen key is worth almost nothing, revocation is rarely needed, and rotation is a normal event rather than an incident — but only because nothing in that loop involves a person.
E2 · Production practice
| Habit | Why |
|---|---|
| Use a separate CA for client certificates, and put nothing else in the trust file | That file is your access control list. Anything in it can produce a credential that gets in |
| Set extendedKeyUsage explicitly on every certificate | A certificate with no EKU is valid for every purpose, which silently removes the error-26 guardrail |
| Add nameConstraints on the day you create an internal CA | It cannot be added later, and without it the CA can impersonate any site on the internet to anyone who trusts it |
| Put the identity in a URI SAN, not a CN | One unambiguous string, no DN ordering trap, and rules can pattern-match a namespace instead of listing every service |
| Build allow-lists from -nameopt RFC2253 output | The proxy sees the reversed, unspaced form. Copying openssl x509 -subject output produces a rule that never matches |
| Assume ssl_verify_client is off until you have proved otherwise | It is the default, and "we configured mTLS" that never enforced anything is the most common failure of all |
| Make the backend unreachable except through the proxy | An identity header is a claim, not a proof. A direct route to the backend defeats perfect mTLS entirely |
| Test positively for SUCCESS, never for the absence of NONE | With optional_no_ca an untrusted certificate produces FAILED:, which a negative test lets straight through |
| Log $ssl_client_v_remain on every request and alarm on it | Client certificate expiry is the most common mTLS outage, and this makes it free to see coming |
| Monitor the CRL's Next Update, not whether the file exists | Internal revocation fails closed. An expired CRL rejects every client at once |
| Set rotationPolicy: Always so renewal issues a new key | Reusing the key means a key stolen once stays valid forever, however short the certificates are |
| Decide how each service picks up a renewed certificate, before you shorten lifetimes | Most TLS libraries read the file once at startup. Fresh on disk, expired in memory, is a real outage |
| Check notAfter on the first certificate from any new role or issuer | Every layer silently truncates lifetimes rather than refusing. You get what the ceiling allows, with no error |
| Write down how to rotate the CA, and re-check the note yearly | A CA nobody knows how to replace is a CA that will expire in production one day |
E3 · Capstone exercise
Brief. In ~/tls-lab/m12/, build mtls-check.sh that:
- Prints the certificate's subject and issuer, and confirms the private key actually matches it
- Checks it is allowed to do client authentication — EKU present and including clientAuth, and says so clearly when EKU is absent, which is permitted but sloppy
- Confirms it is a leaf and not a CA certificate
- Reports how many days are left, working on both GNU and BSD/macOS date
- Prints the identity in RFC 2253 form, and the SPIFFE ID if there is one
- Connects to the endpoint twice — with and without the certificate — and says plainly when the server responds the same both ways, because that means mTLS is not being enforced
✅ Model answer — attempt it first, then click
#!/usr/bin/env bash
# mtls-check — is this client certificate fit for purpose, and does the server enforce mTLS?
cert="$1"; key="$2"; host="$3"; port="${4:-443}"; ca="${5:-}"
[ -z "$cert" ] && { echo "usage: $0 <client.crt> <client.key> [host] [port] [server-ca.crt]"; exit 2; }
echo "=== the certificate ==="
openssl x509 -in "$cert" -noout -subject -issuer | sed 's/^/ /'
# ---------- 1. does the key match the certificate? ----------
c=$(openssl x509 -in "$cert" -noout -pubkey | openssl sha256 | awk '{print $NF}')
k=$(openssl pkey -in "$key" -pubout 2>/dev/null | openssl sha256 | awk '{print $NF}')
[ "$c" = "$k" ] && echo " key match : ok" || echo " key match : MISMATCH - this key does not belong to this certificate"
# ---------- 2. is it allowed to be a client? ----------
eku=$(openssl x509 -in "$cert" -noout -ext extendedKeyUsage 2>/dev/null | tail -n +2 | xargs)
case "$eku" in
*"Client Authentication"*) echo " EKU : ok ($eku)" ;;
"") echo " EKU : absent - permitted, but be explicit; add clientAuth" ;;
*) echo " EKU : NOT VALID FOR CLIENT AUTH ($eku)" ;;
esac
# ---------- 3. is it a leaf? ----------
openssl x509 -in "$cert" -noout -ext basicConstraints | grep -q "CA:FALSE" \
&& echo " leaf : ok" || echo " leaf : this is a CA certificate, not an end-entity certificate"
# ---------- 4. how long is left? (GNU and BSD/macOS) ----------
end=$(openssl x509 -in "$cert" -noout -enddate | cut -d= -f2)
now=$(date +%s); exp=$(date -d "$end" +%s 2>/dev/null || date -j -f "%b %e %T %Y %Z" "$end" +%s)
echo " expires : $end ($(( (exp-now)/86400 )) days left)"
# ---------- 5. the identity a proxy will actually see ----------
echo " RFC2253 : $(openssl x509 -in "$cert" -noout -subject -nameopt RFC2253 | cut -d= -f2-)"
sid=$(openssl x509 -in "$cert" -noout -ext subjectAltName 2>/dev/null | sed -n 's/.*URI:\(spiffe:[^,]*\).*/\1/p')
[ -n "$sid" ] && echo " SPIFFE ID : $sid"
# ---------- 6. does the server actually enforce? ----------
[ -z "$host" ] && exit 0
echo "=== $host:$port ==="
opt=""; [ -n "$ca" ] && opt="--cacert $ca"
# RESOLVE=host:port:ip lets you test a lab name with no DNS entry
[ -n "$RESOLVE" ] && opt="$opt --resolve $RESOLVE"
n=$(curl -s -o /dev/null -w '%{http_code}' $opt "https://$host:$port/" 2>/dev/null)
y=$(curl -s -o /dev/null -w '%{http_code}' $opt --cert "$cert" --key "$key" "https://$host:$port/" 2>/dev/null)
echo " without a client cert : ${n:-connection refused}"
echo " with this client cert : ${y:-connection refused}"
if [ "$n" = "$y" ] && [ "$n" != "000" ]; then
echo " !! the server responds the same either way - mTLS is NOT being enforced here"
else
echo " enforcement looks real"
fiRun it against a server that enforces, and one that does not:
chmod +x ~/tls-lab/m12/mtls-check.sh
cd ~/tls-lab/m12
nginx -c $PWD/nginx.conf && sleep 1
RESOLVE=api.lab.test:8443:127.0.0.1 ./mtls-check.sh payments-svc.crt payments-svc.key api.lab.test 8443 server-ca.crt
nginx -s stop -c $PWD/nginx.conf
# now the same server with verification turned off
sed 's/ssl_verify_client on;/ssl_verify_client off;/' nginx.conf > nginx-off.conf
sed -i "s|logs/nginx.pid|logs/nginx-off.pid|; s|logs/error.log|logs/error-off.log|" nginx-off.conf
nginx -c $PWD/nginx-off.conf && sleep 1
RESOLVE=api.lab.test:8443:127.0.0.1 ./mtls-check.sh payments-svc.crt payments-svc.key api.lab.test 8443 server-ca.crt | tail -5
nginx -s stop -c $PWD/nginx-off.conf=== the certificate ===
subject=CN = payments-svc, O = Lab Ltd
issuer=CN = Lab Client CA
key match : ok
EKU : ok (TLS Web Client Authentication)
leaf : ok
expires : Sep 25 13:18:28 2026 GMT (29 days left)
RFC2253 : O=Lab Ltd,CN=payments-svc
=== api.lab.test:8443 ===
without a client cert : 400
with this client cert : 200
enforcement looks real
RFC2253 : O=Lab Ltd,CN=payments-svc
=== api.lab.test:8443 ===
without a client cert : 200
with this client cert : 200
!! the server responds the same either way - mTLS is NOT being enforced here1. Point 6 is the reason this script is worth having. Everything above it inspects a file, which is useful but not urgent. The last three lines answer a question nobody can answer by reading configuration: is this actually on? Two curl calls and a comparison, and the "we deployed mTLS six months ago" claim is either confirmed or demolished.
2. Comparing status codes is deliberately crude, and that is the strength. It makes no assumptions about the server, the proxy, the framework or the language. Any endpoint that treats an authenticated and an unauthenticated caller identically is not enforcing anything, whatever its config file says.
3. The key-match check catches a genuinely common mistake. Comparing the SHA-256 of the certificate's public key with the SHA-256 derived from the private key is the same trick as Module 02's key↔certificate match, and it is the first thing to run when a client fails for no visible reason after a rotation — very often the certificate was renewed and the key file was not replaced.
4. RESOLVE exists so nothing edits /etc/hosts. It is the same discipline every module has used: test lab names with curl --resolve, and leave the machine exactly as you found it.
What is still missing, honestly: the script does not verify the chain against the server's advertised CA list, does not check a CRL, does not distinguish 400 caused by a missing certificate from 400 caused by an incomplete chain, and treats any difference in status codes as proof of enforcement — a server that returns 500 for authenticated callers would be reported as working. It tells you where to look. It does not tell you everything is correct.
🔑 Keep this file. Module 13 merges certinfo, tlsinfo, tlsdiag, tlsdeploy-check, crl-health, acme-health, ct-caa-audit and mtls-check into one auditing tool you can point at a whole estate.
E4 · Official documentation — what to bookmark and how to read it
Make it a reflex: before assuming a directive does what its name suggests, read its default. ssl_verify_client off, ssl_verify_depth 1, and Apache's SSLCARevocationCheck none between them account for a large share of mTLS deployments that never worked.
Core reference pages
| Link | What it is for |
|---|---|
| nginx ngx_http_ssl_module | Every mTLS directive and every $ssl_client_* variable, with defaults |
| Apache mod_ssl | SSLVerifyClient, SSLVerifyDepth, SSLCARevocationCheck, and the SSL_CLIENT_* variables |
| RFC 8446 §4.3.2 — Certificate Request | The message that starts client authentication, and what it carries |
| RFC 5280 §4.2.1.12 — Extended Key Usage | clientAuth versus serverAuth, and what "no EKU" means |
| RFC 5280 §4.2.1.10 — Name Constraints | How to limit what an internal CA may ever issue for |
| RFC 4514 — String Representation of DNs | Why $ssl_client_s_dn is reversed and unspaced |
| cert-manager — Certificate resource | Every field, including renewBefore and privateKey.rotationPolicy |
| cert-manager — Issuer and ClusterIssuer | Where certificates come from, and namespace versus cluster scope |
| Vault PKI secrets engine | Roles, TTL ceilings, ephemeral certificates, and the CA lifecycle |
| Vault PKI — considerations | The operational advice, including why short TTLs beat revocation |
| SPIFFE concepts | SPIFFE ID, trust domain, SVID, the Workload API |
| The X509-SVID specification | The exact certificate rules — one URI SAN, CA:FALSE, EKU, critical SAN |
| openssl s_server | -Verify, -verify_return_error, and the rest of the test-server flags |
| openssl verify | -purpose, and the full list of verification error codes |
The verify error codes this module added
Module 07 started this table. mTLS adds four, and between them they cover almost every failure you will meet:
20 unable to get local issuer certificate <- the CA is not in the server's trust file
21 unable to verify the first certificate <- the CLIENT forgot to send its intermediate
23 certificate revoked <- it is on the CRL (C5)
26 unsuitable certificate purpose <- wrong EKU: a serverAuth cert used as a client (B1)
47 permitted subtree violation <- the CA was not allowed to issue that name (D5)The offline alternative
Everything in Parts A–C can be checked with no network and no server:
openssl x509 -in client.crt -noout -purpose # ⭐ what may this certificate do?
openssl verify -CAfile client-ca.crt -purpose sslclient c.crt # ⭐ would a server accept it?
openssl x509 -in client.crt -noout -subject -nameopt RFC2253 # ⭐ the string a proxy will see
openssl x509 -in client.crt -noout -ext extendedKeyUsage # just the EKU
openssl s_server -help | grep -i verify # -Verify, -verify_return_error
nginx -t -c /path/to/nginx.conf # ⭐ config syntax, before reloading🧪 Exercise E4.1 — Ask OpenSSL whether a certificate is allowed to be a client, without any server
cd ~/tls-lab/m12
echo "=== would a server accept the client certificate?"
openssl verify -CAfile client-ca.crt -purpose sslclient payments-svc.crt
echo "=== and the server certificate, offered as a client?"
openssl verify -CAfile server-ca.crt -purpose sslclient server.crt
echo "=== the full purpose list for one certificate:"
openssl x509 -in payments-svc.crt -noout -purpose | head -6✅ Expected result — click to reveal
=== would a server accept the client certificate?
payments-svc.crt: OK
=== and the server certificate, offered as a client?
CN = api.lab.test
error 26 at 0 depth lookup: unsuitable certificate purpose
error server.crt: verification failed
=== the full purpose list for one certificate:
Certificate purposes:
SSL client : Yes
SSL client CA : No
SSL server : No
SSL server CA : No
Netscape SSL server : NoWhat to read out of this — you just reproduced Exercise B1.1 with no server, no port and no network.
- -purpose sslclient makes openssl verify apply the same EKU rule a real server applies. Same error 26, same reasoning, in a command you can run in a pipeline.
- openssl x509 -purpose is the fastest answer to "what is this certificate for?" It reads the key usage and EKU and tells you in plain English, instead of making you interpret extensions.
- SSL client : Yes and SSL server : No on the same certificate is exactly the separation from B1, stated by OpenSSL itself.
🔑 This is the check to put in CI. Before a client certificate is deployed anywhere, run openssl verify -CAfile <the CA the server trusts> -purpose sslclient <the certificate>. If it does not say OK there, it will not work in production either — and finding that out in a pipeline is considerably cheaper than finding it out during a rollout.
E5 · Self-assessment
Answer each out loud before opening it. If your answer is materially thinner than the one behind the toggle, that topic is worth a second pass.
1. What does mTLS add to ordinary TLS, and when does the check happen?
The client presents a certificate too, so both identities are proved. The server sends a CertificateRequest; the client replies with its Certificate and a CertificateVerify signature made with its private key.
It happens during the handshake, before any application data. An unknown client never reaches the application at all — which also means the application's logs will not show the attempt.
2. Which handshake message is the actual proof of identity, and why?
CertificateVerify. The certificate itself is a public document — anyone can send a copy of yours. What nobody else can do is sign the handshake transcript with your private key.
This is why a stolen certificate file alone is harmless, and why the private key is the only thing that genuinely needs protecting.
3. Why should client certificates come from a different CA than server certificates?
Because the CA list a server trusts for clients is effectively the list of everyone allowed in. A CA that also signs server certificates makes every one of those a potential credential.
They also have different lifecycles — server certificates come from a public CA via ACME, client certificates are yours and are issued at deployment.
4. What does verify error 26 mean, and what causes it?
Unsuitable certificate purpose — the chain and dates are fine, and this certificate is not permitted to do this job. Almost always a serverAuth-only certificate being offered for client authentication.
It is enforced by both OpenSSL and nginx. But note that a certificate with no EKU extension is valid for all purposes, so omitting EKU removes the protection entirely.
5. Why is $ssl_client_s_dn not the same string as openssl x509 -subject?
nginx uses the RFC 4514 / RFC 2253 form, which writes the DN from most specific to least and drops the spaces: O=Lab Ltd,CN=payments-svc. OpenSSL's default output is the other order with spaces: CN = payments-svc, O = Lab Ltd.
Allow-lists built by copying OpenSSL's default output silently never match. Use openssl x509 -subject -nameopt RFC2253 to get the string the proxy will actually see.
6. A client certificate is valid and trusted, and nginx returns 400. What are the two likely causes?
The client is sending only its leaf and not the intermediate that issued it, so the server cannot build the chain — OpenSSL error 21, unable to verify the first certificate. Fix it by concatenating leaf then intermediate into the --cert file.
Or ssl_verify_depth, which defaults to 1 in nginx while Apache defaults to 10, is too low for your PKI. Either way the reason appears only in the server's error log, not to the client.
7. Does mTLS give you access control?
No. It gives you a verified identity. Every certificate your trusted CA has ever issued will pass, including ones belonging to services that have no business calling this endpoint.
Authorisation is a separate thing you must build — a map on the identity in nginx, a policy engine, or a service mesh. Teams routinely stop after authentication and believe they have both.
8. Why is ssl_verify_client optional risky, and what is the specific bug to avoid?
Because it lets everybody in and leaves enforcement to you, so any protected path where you forget the check is simply open.
The specific bug is writing the check as if ($ssl_client_verify = NONE). With optional_no_ca, an untrusted certificate produces FAILED:... rather than NONE, so that test lets it through. Always test positively for SUCCESS.
9. Your proxy passes the client identity as a header. What must also be true?
The backend must be unreachable except through the proxy. The certificate was the proof and it was consumed at the proxy; everything after that is the proxy's word for it, and a header can be typed by anyone who can reach the port.
The proxy must also set the header unconditionally so a client-supplied one is always overwritten — proxy_set_header does this. But the network path is the part that actually matters.
10. Module 09 said revocation is broken. Why does it work in an internal PKI?
Because you own the CA and every server that checks, and the CRL is a local file. There is no network call to a third party, so there is nothing to soft-fail on. In nginx it is one directive, ssl_crl, and a revoked client is refused with error 23.
The risk inverts, though: internal revocation fails closed. A missing, malformed or expired CRL rejects every client at once, so monitor the CRL's Next Update and remember nginx only re-reads it on reload.
11. What breaks when you move from 90-day to 24-hour certificates?
Anything that assumed a human was involved. Renewal must be a control loop rather than a task, distribution must be automatic, and — most commonly missed — the service must actually pick up the new certificate, since most TLS libraries read the file once at startup.
Also privateKey.rotationPolicy, which defaults to Never in cert-manager and reuses the same key on every renewal, removing most of the security benefit you shortened the lifetime for.
12. What is a name constraint, and why is it a day-one decision?
A nameConstraints extension limiting which names a CA may issue for. Certificates outside the permitted set fail with error 47, permitted subtree violation, on every client that enforces it — so an internal CA in your trust stores can no longer impersonate arbitrary sites on the internet.
It is enforced by the verifier, not the issuer, so it survives the CA being compromised. And it cannot be added afterwards without reissuing the CA and everything beneath it, which is why it has to be set when the CA is created.
E6 · Command reference — everything from this module
Inspect a client certificate
openssl x509 -in client.crt -noout -purpose # ⭐ what is this certificate allowed to do?
openssl x509 -in client.crt -noout -subject -nameopt RFC2253 # ⭐ the string nginx will actually see
openssl x509 -in client.crt -noout -ext extendedKeyUsage # just the EKU
openssl x509 -in client.crt -noout -ext subjectAltName # the SPIFFE ID, if there is one
openssl verify -CAfile client-ca.crt -purpose sslclient client.crt # ⭐ would a server accept it?Test a server, and test it honestly
openssl s_server -accept 4433 -cert server.crt -key server.key \
-CAfile client-ca.crt -Verify 1 -verify_return_error -www -quiet # ⭐ -verify_return_error is NOT optional
echo | openssl s_client -connect host:443 2>&1 \
| sed -n '/Acceptable client certificate CA names/,/Peer signing/p' # ⭐ which CA does the server want?
curl --cacert server-ca.crt --cert client.crt --key client.key https://host/ # ⭐ connect as a client
cat leaf.crt intermediate.crt > chain.crt # ⭐ the fix for error 21Run the CA
openssl ca -batch -config ca.cnf -extensions cli_ext -in svc.csr -out svc.crt # ⭐ issue, and record it
openssl ca -config ca.cnf -revoke svc.crt -crl_reason keyCompromise # ⭐ revoke
openssl ca -config ca.cnf -gencrl -out client-ca.crl # ⭐ regenerate the CRL
openssl crl -in client-ca.crl -noout -lastupdate -nextupdate # ⭐ is the CRL still fresh?
cat ca/index.txt # the ledgernginx
nginx -t -c /path/nginx.conf # ⭐ check the config before touching anything
nginx -c /path/nginx.conf # start with your own config
nginx -s reload -c /path/nginx.conf # ⭐ required after regenerating a CRL
nginx -s stop -c /path/nginx.conf # stop it againThe directives worth memorising, with their defaults:
ssl_client_certificate /path/client-ca.crt; # the trust list = the access control list
ssl_verify_client on; # DEFAULT IS off
ssl_verify_depth 2; # DEFAULT IS 1 (Apache defaults to 10)
ssl_crl /path/client-ca.crl; # re-read only on reload# 1. is the server even enforcing it?
curl -s -o /dev/null -w "no cert: %{http_code}\n" https://host/
# 2. which CA does the server want?
echo | openssl s_client -connect host:443 2>&1 | grep -A2 "Acceptable client"
# 3. who issued the client's certificate, and is it allowed to be a client?
openssl x509 -in client.crt -noout -issuer -purpose | head -3
# 4. what did the SERVER actually log? (the client only ever sees "400")
tail -20 /var/log/nginx/error.log | grep -i "client SSL certificate"Step 4 is the one people skip and the one that contains the answer. The client sees a bare 400; the reason — error 21, 23 or 26 — appears only in the server's log.
This module ended with a script, and so did the seven before it. certinfo, tlsinfo, tlsdiag, tlsdeploy-check, crl-health, acme-health, ct-caa-audit and now mtls-check — eight tools, each answering one question about one host.
Module 13 puts them together and points them at an estate. It covers expiry monitoring that catches problems before they page you, s_client workflows for the failures that only happen in production, the external scanners worth trusting (testssl.sh, sslscan, SSL Labs) and what each is actually good for, rotation runbooks, what changes when TLS terminates at a load balancer or CDN instead of your server, and how to respond when a key is compromised at 2am.
Official reading ahead of it: the testssl.sh documentation, the openssl s_client manual, and SSL Labs' Server Rating Guide.
📚 Sources for the interview questions
Question selection was cross-referenced against publicly published 2026 SSL/TLS and PKI interview question sets, then rewritten and deepened:
- Teleport — What is Mutual TLS (mTLS)?
- Cloudflare Learning — What is mutual TLS?
- ClimbTheLadder — PKI interview questions
Every command and expected output in Parts A, B and C was executed on OpenSSL 3.0.13, nginx 1.24.0 and curl 8.5.0 — including all four verify errors (21, 23, 26, 47), the real nginx 400 No required SSL certificate was sent page, the $ssl_client_* variable output, the CRL revocation taking effect, and the C4 bypass, which genuinely does return the spoofed identity when the backend is reached directly.
One correction is worth recording, because it is a trap in a great deal of published material: an early draft of Exercise A3.1 used openssl s_server -Verify 1 without -verify_return_error. Testing showed the server printed the verification error and completed the handshake anyway, accepting a certificate from an entirely unrelated CA. The exercise was rewritten around the correct flag, and the trap is now called out explicitly, because a lab built without it appears to prove mTLS is working when it is not.
Part D is documentation-verified rather than execution-verified for cert-manager, Vault and SPIRE — none was installed, deliberately, since none belongs on a work laptop for a lab. The behaviour described was checked against primary sources: the cert-manager Certificate reference for duration, renewBefore, usages, uris and the rotationPolicy: Never default; the Vault PKI documentation for roles, pki/issue/<role>, TTL ceilings and ephemeral certificates; the SPIFFE concepts page and the X509-SVID specification for the SVID rules. The OpenSSL equivalents in D2, D3 and D4 were executed, and are there so you can see the mechanism rather than take the tools on trust.
Directive defaults were read directly from the nginx and Apache references rather than from memory — in particular ssl_verify_client off, ssl_verify_depth 1 against Apache's SSLVerifyDepth 10, and SSLCARevocationCheck none.
Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what actually differentiates a candidate in the room.