Module 12 — mTLS & Internal PKI

Updated 26 August 2026

Module 12 · mTLS & Internal PKI

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.


The picture to hold in your head for this whole module — the door that checks you back.

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.

Build the lab. Everything in Parts A–C runs offline inside ~/tls-lab/m12/. You will build two small CAs, issue a few certificates, and run a local web server on a high port. Nothing touches your system trust store.
bash
mkdir -p ~/tls-lab/m12 && cd ~/tls-lab/m12

Part 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:

bash
pkill -f 'nginx -c /tmp' 2>/dev/null; pgrep -f 'openssl s_server' | xargs -r kill
rm -rf ~/tls-lab/m12

All 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

The analogy — the password versus the staff pass.

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 TLSmTLS
Server proves itselfYes — sends a certificateYes — sends a certificate
Client proves itselfNo. The connection is set up first, then the app asks for a password or tokenYes — sends a certificate too, during the handshake
When identity is knownAfter the connection, inside the applicationBefore the connection completes. An unknown client never gets to send a request
What is sent over the wireThe secret itself — password, API key, bearer tokenA public certificate plus a signature. The private key is never transmitted
If the secret leaksAnyone holding it is you, anywhere, until it is rotatedThere is nothing to leak in transit. Stealing the identity means stealing a file from a machine
The sentence that makes mTLS click: the check happens before the application exists.

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
bash
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
plain text
no client cert -> HTTP 200

What 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

The analogy — the doorman who names the agency.

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.

MessageWhat 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 → serverThe client's certificate, plus any intermediates needed to build the chain. May be empty if the client has nothing suitable
CertificateVerify client → serverA signature over the handshake so far, made with the client's private key. This is the actual proof — the certificate alone proves nothing
Why CertificateVerify is the message that matters. A certificate is a public document. Anyone can download yours and send it. What nobody else can do is sign the handshake with your private key.

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
bash
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
plain text
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

The analogy — four things the doorman checks, in order.

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.

CheckWhat it means for a client certificate
Chain to a trusted CAMust chain to a CA the server trusts for clients. This is a separate list from the server's own trust store
Signature and datesIdentical to Module 07. Signature valid, notBefore passed, notAfter not passed
Extended Key UsageMust allow clientAuth. A certificate marked serverAuth only is not valid for a client
RevocationChecked against a CRL you supply. Unlike the public web (Module 09), this one actually works — covered in C5
The hostnameNot checked, and there is nothing to check it against. A client has no hostname. This is the big difference
The most dangerous misunderstanding in this entire module: mTLS tells you who — it does not tell you what they may do.

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
bash
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
plain text
--- (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 Ltd

What 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

The analogy — the staff pass and the visitor badge come from different machines.

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.

Extended Key Usage is the field that keeps the two apart, and it is enforced.

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
bash
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
plain text
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 purpose

What 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

The analogy — what is printed on the pass.

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 livesWhat it is like to work with
CN in the SubjectThe 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 SubjectUseful for coarse grouping — OU=platform. Lets you write one rule for a whole team rather than one per service
A URI SANThe 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 SANThe right choice for people rather than services — a client certificate issued to a human
The serial numberUnambiguous and completely meaningless to a human. Good for audit trails, terrible for rules
The trap that will cost you an afternoon: the same DN is written two different ways.

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
bash
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
plain text
=== 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/checkout

What 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-svcreversed, 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

The analogy — the reception book.

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:

plain text
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
bash
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
plain text
=== 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 GMT

What 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 recorded1000, 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

The analogy — three settings on the doorman.

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:

DirectiveWhat it does
ssl_client_certificateThe CAs you trust to sign client certificates. This list is sent to every client so it can pick the right certificate
ssl_trusted_certificateThe 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_clienton | off | optional | optional_no_ca. Default is off — configuring the CA file alone does nothing
ssl_verify_depthHow many intermediates are allowed. nginx defaults to 1; Apache defaults to 10
ssl_crlA CRL file of revoked client certificates. Covered in C5

Once a client authenticates, nginx hands you the identity in variables:

VariableValue
$ssl_client_verifySUCCESS, NONE (no certificate sent), or FAILED:reason
$ssl_client_s_dnThe subject DN, in RFC 4514 form — reversed and unspaced, as B2 warned
$ssl_client_i_dnThe issuer DN, same format
$ssl_client_serialThe serial number, uppercase hex
$ssl_client_v_remainDays until the certificate expires. Excellent thing to log or alarm on
$ssl_client_escaped_certThe 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
bash
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
plain text
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 days

What 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

The analogy — the pass that references a manager you have never met.

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
bash
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
plain text
=== (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 days

What 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

The analogy — the doorman writes it down, the office decides.

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.

ValueWhen to use it
onThe whole server is for authenticated clients only, and one rule fits all of it. Simplest, and the right default for an internal API
optionalSome paths are public and some are not, or you need per-identity rules. You must then enforce it yourself — nginx will not
optional_no_caRequest 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 and optional_no_ca both let unauthenticated clients through by design. If you set either and forget to check $ssl_client_verify on a protected path, that path is open to the world.

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
bash
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
plain text
=== /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 authorised

What 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 analogy — the side door nobody checks.

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:

plain text
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 thing

The application then trusts X-Client-DN. And that trust is only worth anything if the application cannot be reached except through the proxy.

The rule: a header carrying identity is a claim, not a proof.

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
bash
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
plain text
=== 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-svc

What 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

The analogy — the list on the doorman's desk.

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.

ServerConfiguration
nginxssl_crl /path/to/client-ca.crl; — PEM format. Intermediates' CRLs go in the same file
ApacheSSLCARevocationFile 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
bash
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
plain text
=== 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

The analogy — bread instead of tinned food.

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 breaksWhy
The ledgerindex.txt is a flat file with no locking. Two issuances at the same instant corrupt it
The CA keySits on a disk, protected by whatever permissions someone set once. Nothing rotates it, nothing audits its use
RenewalSomebody must remember. Nobody remembers 500 things
DistributionGetting the new certificate onto the machine, and reloading the service, is a separate manual step every time
RevocationRegenerate 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
bash
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
plain text
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.67

What 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

The analogy — the standing order.

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:

ObjectWhat it is
IssuerWhere certificates come from — your internal CA, Vault, an ACME server. Namespace-scoped
ClusterIssuerThe same, usable from every namespace. What you normally want for an internal CA
CertificateA 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.

yaml
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 certificate
Two fields in that file carry most of the security value, and both have unhelpful defaults.

privateKey.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.

The trap nobody warns you about: your application probably will not notice.

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
bash
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
plain text
=== 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 Authentication

What 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

The analogy — the key-cutting counter with a shop policy.

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.

bash
# 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=1h
The idea worth taking from Vault even if you never use Vault: an ephemeral certificate.

Vault 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."

The trap: max_lease_ttl on the mount is a hard ceiling, and it is set once at enable time.

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:

bash
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
plain text
=== 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 2026

What 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

The analogy — the bootstrap problem, and how a nursery solves it.

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:

TermWhat it means
SPIFFE IDA name, written as a URI: spiffe://lab.test/ns/payments/sa/checkout. lab.test is the trust domain, the rest identifies the workload
SVIDThe document proving the ID. An X509-SVID is an ordinary certificate carrying the SPIFFE ID in a URI SAN
Workload APIA 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:

RuleWhy
Exactly one URI SANOne certificate, one identity. Two would make "who is this?" ambiguous, and a validator must reject it
basicConstraints CA:FALSEA workload identity must never be able to issue further identities
keyUsage critical, with digitalSignatureNeeded for the CertificateVerify signature from A2. keyCertSign is forbidden on a leaf
extendedKeyUsage with both serverAuth and clientAuthA workload both calls and is called, as B2 showed
Subject may be empty — and then the SAN must be criticalIf 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
bash
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
plain text
== 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

The analogy — the master key you are asked to keep in a drawer.

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.

SituationWhat to do
Anything on the public internetPublic CA, via ACME. Never your own. Module 10 covers it, browsers already trust it, and it is free
Internal service-to-service, in Kubernetescert-manager, or a service mesh that runs SPIRE for you. Do not hand-roll
Internal, mixed estate, you already run VaultVault PKI. The CA key gets real protection and you get roles and audit for free
Internal, and your cloud provider offers a private CAUse it. You are paying for someone else to hold the key safely, which is the expensive part
Air-gapped, regulated, or genuinely nothing else fitsRun 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.

Why name constraints matter so much: an unconstrained internal CA can impersonate the entire internet on every machine that trusts it.

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
bash
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
plain text
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 failed

What 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:2px

Read 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

HabitWhy
Use a separate CA for client certificates, and put nothing else in the trust fileThat file is your access control list. Anything in it can produce a credential that gets in
Set extendedKeyUsage explicitly on every certificateA 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 CAIt 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 CNOne unambiguous string, no DN ordering trap, and rules can pattern-match a namespace instead of listing every service
Build allow-lists from -nameopt RFC2253 outputThe 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 otherwiseIt 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 proxyAn 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 NONEWith 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 itClient 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 existsInternal revocation fails closed. An expired CRL rejects every client at once
Set rotationPolicy: Always so renewal issues a new keyReusing 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 lifetimesMost 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 issuerEvery 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 yearlyA CA nobody knows how to replace is a CA that will expire in production one day

E3 · Capstone exercise

Build one tool that answers the two questions you will actually be asked: is this client certificate fit for purpose, and is the server on the other end really enforcing mTLS, or only configured for it?

Brief. In ~/tls-lab/m12/, build mtls-check.sh that:

  1. Prints the certificate's subject and issuer, and confirms the private key actually matches it
  2. 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
  3. Confirms it is a leaf and not a CA certificate
  4. Reports how many days are left, working on both GNU and BSD/macOS date
  5. Prints the identity in RFC 2253 form, and the SPIFFE ID if there is one
  6. 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
bash
#!/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"
fi

Run it against a server that enforces, and one that does not:

bash
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
plain text
=== 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 here

1. 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

The single most useful document for this module: the nginx ngx_http_ssl_module reference. It is one page, it lists every ssl_client_* variable with the nginx version that introduced it, and — the part that matters — it states every default next to every directive.

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

LinkWhat it is for
nginx ngx_http_ssl_moduleEvery mTLS directive and every $ssl_client_* variable, with defaults
Apache mod_sslSSLVerifyClient, SSLVerifyDepth, SSLCARevocationCheck, and the SSL_CLIENT_* variables
RFC 8446 §4.3.2 — Certificate RequestThe message that starts client authentication, and what it carries
RFC 5280 §4.2.1.12 — Extended Key UsageclientAuth versus serverAuth, and what "no EKU" means
RFC 5280 §4.2.1.10 — Name ConstraintsHow to limit what an internal CA may ever issue for
RFC 4514 — String Representation of DNsWhy $ssl_client_s_dn is reversed and unspaced
cert-manager — Certificate resourceEvery field, including renewBefore and privateKey.rotationPolicy
cert-manager — Issuer and ClusterIssuerWhere certificates come from, and namespace versus cluster scope
Vault PKI secrets engineRoles, TTL ceilings, ephemeral certificates, and the CA lifecycle
Vault PKI — considerationsThe operational advice, including why short TTLs beat revocation
SPIFFE conceptsSPIFFE ID, trust domain, SVID, the Workload API
The X509-SVID specificationThe 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:

plain text
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:

bash
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
bash
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
plain text
=== 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 : No

What 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

The commands introduced in Module 12, grouped by what you are trying to achieve. The ones marked ⭐ are genuinely daily-use.

Inspect a client certificate

bash
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

bash
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 21

Run the CA

bash
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 ledger

nginx

bash
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 again

The directives worth memorising, with their defaults:

plain text
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
The four-command reflex when somebody says "mTLS is broken". In this order, because it goes from cheapest to most expensive:
bash
# 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.


Next — Module 13 · Troubleshooting & Operating TLS at Scale.

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:

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.

Spotted a mistake or want something added? Send me a note.