Module 04 — CSRs & Self-Signed Certificates

Updated 20 August 2026

Module 04 · CSRs & Self-Signed Certificates

Until now you have only read certificates other people made. This module is where you make your own — a signing request, a self-signed certificate, and finally a certificate you signed yourself. Along the way you will find out why the CA throws away most of what you put in your request, and why a certificate that passes every openssl and curl test can still be refused by a browser.

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01, 02 and 03. You need digital signatures (Module 01, B5), key files and PEM labels (Module 02), and every certificate field from Module 03 — especially SAN, basicConstraints and Key Usage.


The picture to hold in your head for this whole module — applying for a passport.

You do not walk into the passport office and receive a passport. You fill in an application form.

On the form you write your name, attach your photo, and sign it yourself at the bottom — not to prove who you are, but to prove that you filled it in and nobody altered it on the way.

The office then does its own checks, ignores half of what you wrote, adds the things only it can add — the passport number, the issue date, its own stamp — and hands you a document that is not the form you submitted.

The form is a CSR. The passport is the certificate. Getting the difference between those two straight is most of this module.

Set up a working directory before you start.
bash
mkdir -p ~/tls-lab/m04 && cd ~/tls-lab/m04
umask 077
openssl version

Everything in this module runs offline. No network, no CA, no cost — which is exactly why it is the right place to experiment. Every command here is safe to run repeatedly.

All expected output in this module was produced on OpenSSL 3.0.13. If you are on OpenSSL 1.1.1 a few flags differ, and those differences are called out where they matter.

Part A · The Certificate Signing Request

A1 · What a CSR is, and what is inside it

A CSR carries exactly three things, and RFC 2986 says so in one sentence:

A certification request consists of a distinguished name, a public key, and optionally a set of attributes, collectively signed by the entity requesting certification.
In the CSRWhat it isForm analogy
Subject DNThe name you are claiming — CN, O, C and so onYour details, handwritten
Public keyThe public half of the key you will useYour photo, stapled on
AttributesOptional extras — in practice, the extensions you would like, especially SANThe "other names" box
SignatureMade with your own private key, over everything aboveYour signature at the bottom
Now the more important list — what a CSR does NOT contain.

No validity dates. No serial number. No issuer. No AIA, no CRL distribution point, no Authority Key Identifier, no CT log receipts.

Every one of those is something only the CA can supply, because every one of them is a statement the CA is making, not a statement you are making. You cannot request a serial number any more than you can write your own passport number on the application form.

This is the cleanest way to remember the difference: a CSR is what you can say about yourself. A certificate is what someone else says about you.

Before typing anything, it is worth knowing which of three artefacts you actually want. openssl req produces all three depending on the flags, which is why copied commands so often produce the wrong thing:

Diagram source
flowchart TD
    Q1{"Will a CA sign this,<br>or are you signing it<br>yourself?"}
    Q1 -->|"a CA will sign it"| Q2{"Do you already have<br>a private key?"}
    Q1 -->|"I am signing it myself"| Q3{"Is this a server cert,<br>or a CA cert?"}
    Q2 -->|"yes"| A["📝 CSR from existing key<br>req -new -key k.key"]
    Q2 -->|"no"| B["📝 CSR + new key<br>req -new -newkey rsa:2048 -noenc"]
    Q3 -->|"server"| C["📜 Self-signed leaf<br>req -x509 with<br>basicConstraints CA:FALSE"]
    Q3 -->|"CA"| D["🏢 Self-signed CA<br>req -x509 with<br>basicConstraints CA:TRUE"]
    A --> S["⚠️ ALL FOUR need<br>-addext subjectAltName<br>except the CA"]
    B --> S
    C --> S
    style A fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
    style B fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
    style C fill:#d5e8d4,stroke:#82b366,stroke-width:2px
    style S fill:#ffcccc,stroke:#cc0000,stroke-width:2px
One flag separates two completely different artefacts. -x509 present means you get a certificate; absent means you get a CSR. That is the whole difference, and it is why a command copied from the wrong tutorial produces something that looks plausible and is not what you needed.

The red box is the thing every path shares: a CA certificate does not need a SAN, because nothing connects to a CA by hostname. Everything else does.

🧪 Exercise A1.1 — Make your first CSR
bash
cd ~/tls-lab/m04

# A key first - the CSR is built around it (Module 02, A1)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out server.key

# Now the request
openssl req -new -key server.key -out server.csr \
  -subj "/C=MY/ST=Selangor/L=Kuala Lumpur/O=Zaeem Labs/CN=shop.example.com"

ls -l server.key server.csr
head -3 server.csr
Expected result — click to reveal
plain text
-rw------- 1 zaeem zaeem 1704 Aug 20 15:02 server.key
-rw------- 1 zaeem zaeem 1074 Aug 20 15:02 server.csr

-----BEGIN CERTIFICATE REQUEST-----
MIICrDCCAZQCAQAwZzELMAkGA1UEBhMCTVkxETAPBgNVBAgMCFNlbGFuZ29yMRUw
EwYDVQQHDAxLdWFsYSBMdW1wdXIxEzARBgNVBAoMClphZWVtIExhYnMxGTAXBgNV

What to read out of this.

  • -----BEGIN CERTIFICATE REQUEST----- — exactly the PEM label from Module 02's table (Part B3). PKCS#10. Not a certificate, not a key, and head -1 tells you so instantly.
  • The CSR is about 1 KB — smaller than a certificate, because it is missing all the fields the CA adds.
  • -subj avoided the interactive prompts. Run openssl req -new -key server.key -out x.csr with no -subj and OpenSSL asks you seven questions one at a time. That is fine once; it is useless in a script, and every automated pipeline uses -subj.
  • The slash-separated format is fussy. /C=MY/ST=Selangor/... — note it is ST, not S (Module 03, B1), and a stray space or a missing slash produces a confusing error.

💡 A shortcut you will see everywhere, which makes the key and the CSR in one command:

bash
openssl req -new -newkey rsa:2048 -noenc -keyout server.key -out server.csr -subj "/CN=shop.example.com"

-noenc is the flag from Module 02 that stops OpenSSL putting a passphrase on the key — called -nodes before OpenSSL 3.0. Forget it and you get an encrypted key your web server cannot read unattended (Exercise A3.2 in Module 02).

🧪 Exercise A1.2 — Open the CSR up and confirm what is missing
bash
cd ~/tls-lab/m04

openssl req -in server.csr -noout -text | head -12

echo "=== does it contain dates, a serial, or an issuer? ==="
openssl req -in server.csr -noout -text \
  | grep -iE 'validity|serial|issuer|not before|not after' \
  || echo "(none - as expected)"

echo "=== the three top-level parts, like a certificate ==="
openssl asn1parse -in server.csr | head -4
Expected result — click to reveal
plain text
Certificate Request:
    Data:
        Version: 1 (0x0)
        Subject: C = MY, ST = Selangor, L = Kuala Lumpur, O = Zaeem Labs, CN = shop.example.com
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:e8:6b:b9:5d:8c:27:64:33:cf:90:a4:cd:50:af:
                    25:b8:f8:59:fe:3a:44:0f:96:72:ba:eb:c6:f7:02:
                    ...
                Exponent: 65537 (0x10001)

=== does it contain dates, a serial, or an issuer? ===
(none - as expected)

=== the three top-level parts, like a certificate ===
    0:d=0  hl=4 l= 684 cons: SEQUENCE
    4:d=1  hl=4 l= 404 cons: SEQUENCE
    8:d=2  hl=2 l=   1 prim: INTEGER           :00
   11:d=2  hl=2 l= 103 cons: SEQUENCE

What to read out of this.

  • The grep found nothing. No Validity, no Serial Number, no Issuer. That is the whole point of the yellow callout above, proven in one command rather than asserted.
  • Version: 1 (0x0) — and here the off-by-one from Module 03 (A2) goes the other way. PKCS#10 version 1 is stored as 0. Certificates are v3 stored as 2. Same zero-based encoding, different current version. Do not confuse a CSR's "version 1" with an X.509 v1 certificate; they are unrelated numbering schemes.
  • Exponent: 65537 — the constant from Module 02 (A2.1), unchanged.
  • The ASN.1 shape mirrors a certificate. Outer SEQUENCE, then certificationRequestInfo, then the algorithm, then the signature. Same three-part contract structure from Module 03 (A1) — body, algorithm, signature — because a CSR is signed the same way a certificate is.

🎯 Interview questions — What a CSR is

Q. What is a CSR and how is it used to obtain an SSL certificate?

A PKCS#10 Certification Request — a small signed block containing a subject distinguished name, a public key, and optionally requested extensions such as SAN, all signed with the corresponding private key.

The flow: generate a key pair on the machine that will use it, build a CSR around it, send the CSR to the CA, the CA validates your control of the domain, and it returns a certificate. The private key never leaves your server — that is the entire reason the CSR exists as a separate artefact.

The detail that shows depth: a CSR contains no validity dates, no serial number and no issuer, because those are statements only the CA can make. It is a request, not a draft certificate. The CA is free to ignore anything in it, and generally does.

And the operational point: because the CSR is derived from the key, losing the key makes any certificate issued from it useless. Key and certificate must travel together through the whole issuance process — which is the root cause of nearly every key values mismatch error (Module 02, D2).

Q. What is the procedure to generate an SSL certificate?

Five steps, and being able to say which are yours and which are the CA's is the point:

  1. Generate a private key on the target machine — openssl genpkey. EC P-256 for a modern web server, RSA 2048 if you need older client support.
  2. Create a CSR around it — openssl req -new -key ... -subj ... -addext "subjectAltName=DNS:...". The SAN is the part that matters; the rest of the DN is mostly cosmetic.
  3. Submit the CSR to the CA and complete validation — for DV that means proving domain control, usually via ACME's HTTP or DNS challenge.
  4. Receive the certificate and its chain, and assemble fullchain.pem — leaf first, then intermediates.
  5. Deploy and reload, then verify what the server is serving rather than what is on disk.

What separates a strong answer: saying plainly that in 2026 you would not do any of this by hand. With lifetimes dropping to 200 days now and 47 days by 2029, the real answer is ACME with certbot, cert-manager or the cloud provider's managed certificates — and the manual procedure is what you fall back to for internal CAs and for debugging.


A2 · Proof of possession — why a CSR signs itself

The analogy — signing the form in front of the clerk.

The clerk does not know your signature. They have never seen it before, so it cannot prove who you are.

So why insist on it?

Because it proves that the hand that wrote the form is the hand that is here now, and that nobody altered the form between you writing it and them reading it. If someone intercepted your application and swapped their photo for yours, the signature would no longer fit the contents.

A CSR's self-signature does exactly this. It does not prove your identity — the CA establishes that separately. It proves you hold the private key matching the public key in the request, and that the request has not been tampered with.

Why this matters, concretely. Without it, anyone could take your public key out of your website's certificate, build a CSR around it claiming to be their-site.com, and get a certificate issued for a key they do not hold.

That certificate would be useless to them — they could not complete a handshake without the private key. But it would be issued in your key's name, and that is a genuine mess: it would appear in Certificate Transparency logs, it could be used to confuse revocation, and it muddies who is responsible for what.

The self-signature closes that door. You cannot request a certificate for a key you do not control.

🧪 Exercise A2.1 — Verify the CSR's own signature
bash
cd ~/tls-lab/m04
openssl req -in server.csr -noout -verify
Expected result — click to reveal
plain text
Certificate request self-signature verify OK

What to read out of this.

  • "self-signature" is OpenSSL 3.x being precise. On OpenSSL 1.1.1 the same command says just verify OK, which is vaguer and led people to think it was verifying something about trust. It is not — it is checking one signature against one public key, both of which came out of the same file.
  • This check proves nothing about identity or trust. It says: the public key inside this request can verify the signature on this request. Nothing more.
  • Every CA runs this before anything else. A CSR that fails here is discarded immediately, and no domain validation is even attempted.
🧪 Exercise A2.2 — Tamper with a CSR and watch the check catch it
bash
cd ~/tls-lab/m04
cp server.csr tampered.csr

# Change a single character in the Base64 body
sed -i '4s/./X/20' tampered.csr

openssl req -in tampered.csr -noout -verify
echo "exit code: $?"
Expected result — click to reveal
plain text
Certificate request self-signature verify failure
exit code: 1

What to read out of this.

  • One character changed out of about a thousand, and the check fails. That is the avalanche property from Module 01 (B4) doing its job — the hash of the modified body is completely different, so the signature no longer fits.
  • Note what did not happen: the file still parsed. openssl req -in tampered.csr -noout -text will still print a subject and a public key quite happily. Parsing and verifying are different operations, and a file that reads fine can still be corrupt.
  • This is why -verify matters in a pipeline. If your automation copies CSRs between systems, verifying on arrival catches truncation, encoding damage and CRLF mangling (Module 02, B2.2) before you waste a CA request on it.

⚠️ A trap worth internalising: depending on which byte you change, you may instead get a parse error rather than a verification failure. Both mean "this CSR is broken", but they come from different stages and look nothing alike. Do not assume a specific error text — check the exit code.

🎯 Interview questions — Proof of possession

Q. Why is a CSR signed, given that the CA does not know your key yet?

It is proof of possession, not proof of identity. The signature demonstrates that whoever built the request holds the private key matching the public key inside it, and that the request has not been altered in transit.

Without it, anyone could lift your public key out of your published certificate, wrap a CSR around it claiming a different domain, and obtain a certificate for a key they do not control. That certificate would be unusable for a handshake, but it would still be issued, logged in Certificate Transparency, and would muddy accountability.

The distinction to state clearly: identity is established separately, by domain validation. The CSR signature and the domain validation answer two different questions — do you hold this key and do you control this name — and a certificate requires both.


A3 · What the CA ignores, and what it adds

The analogy — what actually happens to your application form.

You wrote your name, address and occupation on the form. The clerk checks your name against their records, crosses out your occupation because they do not record that, ignores the address you wrote and uses the one on file, and then types in a passport number, an issue date and an expiry date — none of which you were asked about.

The passport you receive shares only two things with your form: your photo and your verified name.

A public CA does exactly this. The only things it reliably takes from your CSR are the public key and, after checking it, the domain name.

Field in your CSRWhat a public CA doesWhy
Public keyUses itThis is the whole point of the request
CN / SAN domain namesUses — after validatingOnly names you proved control of survive. Others are dropped or the request is rejected
O, L, ST, CIgnored for DV. Replaced with verified data for OV/EVNobody checked what you typed, so it cannot be published
OUDroppedBanned in public certificates since 2022 (Module 03, B1)
Requested basicConstraintsIgnoredYou do not get to ask to be a CA. See Exercise D1.2 for what happens when this is honoured
Requested Key Usage / EKUIgnored — the CA applies its own profileThe CA's issuance policy decides, not the requester
Validity, serial, issuer, AIA, CRL DP, SCTsAdded by the CANot present in a CSR at all — these are the CA's statements
The consequence people find genuinely surprising: you can put whatever you like in a CSR's organisation field, and it will be accepted into the CSR without complaint.

Putting O=Global Bank PLC in a CSR is not forgery and triggers no alarm — because the CSR is only a request, and the CA is going to ignore it. For a DV certificate the field simply never reaches the certificate.

This is why O in a certificate means something only when the policy OID says OV or EV (Module 03, C6). On a DV certificate, an organisation field would be an unverified claim, which is exactly why DV certificates do not have one.

🧪 Exercise A3.1 — Put an obviously false organisation in a CSR
bash
cd ~/tls-lab/m04

openssl req -new -key server.key -out fake.csr \
  -subj "/C=GB/O=Global Bank PLC/OU=Vault Division/CN=shop.example.com"

openssl req -in fake.csr -noout -subject
openssl req -in fake.csr -noout -verify
Expected result — click to reveal
plain text
subject=C = GB, O = Global Bank PLC, OU = Vault Division, CN = shop.example.com
Certificate request self-signature verify OK

What to read out of this — and it is worth sitting with for a moment.

  • No warning, no error, valid signature. OpenSSL does not care what you claim, and neither does the CSR format. A CSR is a request; anyone may request anything.
  • Submit this to a public CA for a DV certificate and you would get back a certificate reading CN = shop.example.com and nothing else. The O and OU would be discarded silently — no error, no note, they simply would not appear.
  • The security does not live in the CSR. It lives in the CA's validation. This is the same lesson as Module 03's trust-store point, from the other side: nothing in a file is trustworthy on its own. Trust comes from a process performed by someone else.

🔑 The practical conclusion, and it saves real time: for a DV certificate, do not agonise over the DN. Set the CN to your primary hostname and put the effort into the SAN list, which is the part that actually gets used. Most of the fields in those "how to generate a CSR" tutorials are ceremony.

💡 Where the DN does matter: private CAs, which normally honour the whole subject you send, and client certificates (Module 12), where applications frequently authorise on the subject DN. In those cases every field counts.

🎯 Interview questions — What the CA does with your request

Q. If you put O=Some Company in your CSR, does it end up in the certificate?

For a DV certificate from a public CA, no — it is discarded silently. DV proves domain control only, so the CA cannot publish an organisation name it never verified.

For OV or EV, an organisation field does appear, but it is the CA's verified version drawn from company registry checks, not the string you typed.

For a private CA, it usually does survive, because private CAs commonly honour the whole subject DN. That is a meaningful difference in behaviour between public and internal PKI, and it matters when applications authorise on subject fields.

The underlying principle worth stating: a certificate should only assert things the issuer actually checked. Public PKI has been steadily deleting unverifiable fields for exactly this reason — OU was banned outright in 2022 on the same logic.

Q. Can a CSR request that its certificate be a CA?

It can request it — basicConstraints: CA:TRUE is perfectly legal to place in a CSR's attributes and produces no error. Whether it is granted depends entirely on the issuer.

No public CA will honour it; issuance profiles are fixed and requested extensions are ignored. But a private CA can be misconfigured to copy extensions from the CSR verbatim, and then a request to be a CA is granted — turning an ordinary server certificate into one that can issue certificates for anything.

The concrete version: OpenSSL's x509 -req drops CSR extensions by default and copies them only with -copy_extensions=copy. That default is a security decision, not an inconvenience, and people routinely add the flag to "fix" a missing SAN without realising what else it lets through.

And the tie-back: this is the 2002 basicConstraints failure (Module 03, C2) approached from the issuance side rather than the validation side. Both ends of the system have to get it right.


Part B · Making CSRs properly

B1 · SAN in a CSR — the part everyone gets wrong

The analogy — the box you left blank.

Halfway down the application form there is a section headed "Other names this document should cover". It looks optional. You skip it.

Weeks later the passport arrives with no names printed in it at all — because that section was not optional after all, it was the only one that mattered, and the "Name" box at the top was a legacy field nobody reads any more.

That is a CSR without SAN. Module 03 (Part B2) explained why the CN stopped counting. This section is where you feel it, because openssl req does not add a SAN unless you tell it to, and it does not warn you.

This is the single most common mistake in this whole track.

Every "how to generate a CSR" tutorial written before about 2018 shows openssl req -new -key server.key -out server.csr with no SAN. That command still runs perfectly, produces a valid CSR, and is what most people copy.

The resulting certificate has no SAN, and browsers refuse it.

And here is the part that makes it genuinely nasty: your command-line tests will pass. As Module 03's verified table showed, openssl and curl still fall back to the CN when SAN is absent. So you test, it works, you deploy, and the browser is the first thing that tells you it is broken.

🧪 Exercise B1.1 — Make the mistake on purpose, then fix it
bash
cd ~/tls-lab/m04

echo "=== the tutorial command - no SAN ==="
openssl req -new -key server.key -out nosan.csr -subj "/CN=shop.example.com"
openssl req -in nosan.csr -noout -text | sed -n '/Attributes/,/Signature Alg/p'

echo
echo "=== the correct command ==="
openssl req -new -key server.key -out san.csr \
  -subj "/CN=shop.example.com" \
  -addext "subjectAltName=DNS:shop.example.com,DNS:www.shop.example.com"
openssl req -in san.csr -noout -text | sed -n '/Attributes/,/Signature Alg/p'
Expected result — click to reveal
plain text
=== the tutorial command - no SAN ===
        Attributes:
            (none)
            Requested Extensions:
    Signature Algorithm: sha256WithRSAEncryption

=== the correct command ===
        Attributes:
            Requested Extensions:
                X509v3 Subject Alternative Name:
                    DNS:shop.example.com, DNS:www.shop.example.com
    Signature Algorithm: sha256WithRSAEncryption

What to read out of this.

  • Attributes: (none) followed by an empty Requested Extensions: heading. That is OpenSSL 3.0's slightly odd way of saying "there is nothing here". It is easy to skim past, which is exactly why the mistake survives review.
  • No warning. No error. Exit code 0. OpenSSL will never tell you that a CSR without SAN is going to produce a certificate browsers reject. It is not OpenSSL's job to know what you intend.
  • -addext is the fix, and it is one flag. Available from OpenSSL 1.1.1 onwards. On anything older you must use a config file, which is section B2.
  • Note the leading subjectAltName= inside the quotes and the comma-separated DNS: prefixes. Each entry is typed (Module 03, B3) — DNS: for hostnames, IP: for literal addresses. Writing an IP after DNS: produces a SAN entry that matches nothing.

🔑 The command worth memorising, because you will type it more than any other in this track:

bash
openssl req -new -newkey rsa:2048 -noenc \
  -keyout server.key -out server.csr \
  -subj "/CN=shop.example.com" \
  -addext "subjectAltName=DNS:shop.example.com,DNS:www.shop.example.com"

Key, CSR, SAN, no passphrase — one command, and it is correct.

💡 Always include your primary name in BOTH the CN and the SAN. The CN is ignored by clients but is still displayed by tools, logs and dashboards, so leaving it empty makes certificates harder for humans to identify. Set it, and duplicate it into the SAN list where it actually counts.

🎯 Interview questions — SAN in a CSR

Q. You generated a CSR, got a certificate back, and Chrome shows ERR_CERT_COMMON_NAME_INVALID. What happened?

The CSR had no subjectAltName, so the certificate has none, and Chrome has not looked at the Common Name since version 58 in 2017. RFC 9525 confirms the CN must not be used for hostname matching.

The cause is almost always a CSR built from an old tutorial: openssl req -new -key k.key -out c.csr with no -addext. The fix is to reissue with -addext "subjectAltName=DNS:..." — you cannot patch an existing certificate, because changing it would invalidate the signature.

The detail that makes this a strong answer: explain why it got through testing. openssl verify -verify_hostname, curl, Python and Node all still fall back to the CN when SAN is absent, so every command-line check passes. Only browsers and Go reject it — Go's error even says certificate relies on legacy Common Name field, use SANs instead.

So the process lesson is: this is one of the few cases where the browser is stricter than your tooling, and a CLI-only test plan will not catch it. Add a browser or Go check to your certificate validation step.


B2 · Config files — when one flag is not enough

The analogy — a saved template of the form.

If you fill in the same application once, -addext on the command line is fine.

If you fill it in every ninety days, for forty services, and it must be identical every time — you want a saved template you can check into version control, review in a pull request, and diff when something changes.

That is what a config file is. It is not more powerful than -addext for simple cases; it is more auditable, and it stops the SAN list living in somebody's shell history.

🧪 Exercise B2.1 — Build a CSR from a config file
bash
cd ~/tls-lab/m04

cat > csr.cnf <<'EOF'
[ req ]
default_bits       = 2048
prompt             = no
distinguished_name = dn
req_extensions     = req_ext

[ dn ]
C  = MY
O  = Zaeem Labs
CN = shop.example.com

[ req_ext ]
subjectAltName = @alt_names

[ alt_names ]
DNS.1 = shop.example.com
DNS.2 = www.shop.example.com
DNS.3 = api.shop.example.com
IP.1  = 10.0.1.15
EOF

openssl req -new -key server.key -out cfg.csr -config csr.cnf

openssl req -in cfg.csr -noout -subject
openssl req -in cfg.csr -noout -text | sed -n '/Attributes/,/Signature Alg/p'
Expected result — click to reveal
plain text
subject=C = MY, O = Zaeem Labs, CN = shop.example.com

        Attributes:
            Requested Extensions:
                X509v3 Subject Alternative Name:
                    DNS:shop.example.com, DNS:www.shop.example.com, DNS:api.shop.example.com, IP Address:10.0.1.15
    Signature Algorithm: sha256WithRSAEncryption

What to read out of this.

  • prompt = no is what makes it non-interactive. Leave it out and OpenSSL asks you to confirm every field, which defeats the point in a script. It is the commonest omission in copied config files.
  • req_extensions = req_ext is the line that wires it up. It names the section holding the extensions. Because it is declared inside [ req ], you do not need -reqexts on the command line — a lot of documentation adds that flag unnecessarily.
  • @alt_names is a section reference. The @ means "the values are in a section with this name". This indirection exists because SAN values contain commas, which would otherwise break the config parser.
  • DNS.1, DNS.2, IP.1 — the numeric suffixes just make the keys unique. They have no meaning and no ordering significance; the config format simply cannot have two keys with the same name.
  • The IP entry becomes IP Address:10.0.1.15, a genuinely different SAN type from DNS: (Module 03, B3). Write DNS.4 = 10.0.1.15 instead and you get a DNS entry containing digits, which matches nothing.

⚠️ A counting trap worth knowing. All the SANs print on one line, so grep -c 'DNS:' returns 1, not 3. To count them properly:

bash
openssl req -in cfg.csr -noout -text | tr ',' '\n' | grep -c 'DNS:'

The same applies to certificates. It has caused more than one monitoring script to report "1 SAN" forever.

🎯 Interview questions — Config files

Q. When would you use a config file rather than -addext?

When the CSR definition needs to be reviewable and repeatable: many SAN entries, a fixed subject shared across a fleet, or a request that gets regenerated on every renewal.

A config file lives in version control, so a change to the SAN list appears in a pull request and is diffable. -addext lives in shell history, where nobody reviews it.

-addext is right for one-offs and for quick tests, and it requires OpenSSL 1.1.1 or later — on anything older, a config file is the only option.

The point worth adding: in production you would rarely write either by hand. ACME clients, cert-manager and Vault build the request for you from a declarative spec. The config file is what you reach for with an internal CA, or when debugging what a tool actually asked for.


B3 · New key or existing key — and what that means for renewal

The analogy — renewing with the same photo, or a new one.

When your passport expires you can submit the same photograph again, or have a new one taken.

Same photo: anything that recognised you before still recognises you. New photo: everything that memorised the old one has to be updated.

The photo is your key. Reusing it keeps any public-key pin working (Module 03, D1). Generating a new one breaks every pin, but limits how long any single key is exposed.

FlagWhat it does
-key existing.keyBuilds the CSR around a key you already have. Reuses the key
-newkey rsa:2048Generates a brand-new key and writes it to -keyout. New key
-newkey ec -pkeyopt ec_paramgen_curve:P-256The same, for an EC key
🧪 Exercise B3.1 — Prove which one changes the key
bash
cd ~/tls-lab/m04

echo "=== a renewal CSR from the SAME key ==="
openssl req -new -key server.key -out renew.csr -subj "/CN=shop.example.com"
diff <(openssl req -in san.csr   -noout -pubkey) \
     <(openssl req -in renew.csr -noout -pubkey) \
  && echo "SAME KEY - existing pins still work"

echo
echo "=== -newkey generates a fresh one ==="
openssl req -new -newkey rsa:2048 -noenc -keyout fresh.key -out fresh.csr \
  -subj "/CN=shop.example.com" 2>/dev/null
diff <(openssl req -in san.csr   -noout -pubkey) \
     <(openssl req -in fresh.csr -noout -pubkey) >/dev/null \
  && echo "same" || echo "DIFFERENT KEY - any public-key pin breaks"
Expected result — click to reveal
plain text
=== a renewal CSR from the SAME key ===
SAME KEY - existing pins still work

=== -newkey generates a fresh one ===
DIFFERENT KEY - any public-key pin breaks

What to read out of this.

  • openssl req -in x.csr -noout -pubkey extracts the public key from a CSR, exactly as openssl x509 -noout -pubkey does from a certificate (Module 03, D1). Same technique, three sources — key, CSR, certificate — which is what makes the diff comparison work across all of them.
  • Reusing the key is the right default for most renewals. Nothing that trusted the old certificate has to change, and any public-key pin keeps working.
  • Generating a new key is right when the old one may have been exposed, it is very old, you are changing algorithm, or your policy requires periodic rotation.

🔑 The mistake this prevents is the classic key values mismatch from Module 02 (D2.2). It happens when someone runs -newkey during a renewal — often without noticing, because it is in the command they copied — deploys the returned certificate, and leaves the old key in place. The certificate is fine, the key is fine, and they do not belong together.

Now imagine this at 500 hosts. The safe pattern is to make key handling explicit rather than incidental: generate the key once, on the target host, and have the renewal job use -key against that file. If you do rotate keys, rotate them deliberately as a separate, logged step — never as a side effect of the renewal command. And run the diff check from Module 02 (D2.1) as a pre-deploy gate, so a mismatch is caught before a restart rather than during one.

🎯 Interview questions — Renewal and keys

Q. Can you explain the process of renewing an SSL certificate?

Renewal is really reissuance — there is no operation that extends an existing certificate, because its dates are inside the signed data and changing them would break the signature. You get a new certificate.

The steps: build a CSR (reusing the existing key, or generating a new one deliberately), revalidate domain control, receive the new certificate and chain, deploy fullchain.pem plus the matching key, reload the service, and verify what the server is now serving.

The two failure points worth naming, because they cause most renewal incidents:

  1. Nothing reloaded. The new file is on disk, the process is still holding the old certificate in memory, and it expires anyway. Monitoring that checks the file rather than the live connection will not see it.
  2. Key and certificate separated. A -newkey in the renewal command produces a new key, only the certificate gets deployed, and the service fails to start with key values mismatch.

The 2026 framing: with lifetimes at 200 days now and 47 days scheduled for 2029, any renewal process involving a human is a scheduled outage. The correct answer is ACME automation with a reload hook and monitoring on the live endpoint.


Part C · Self-signed certificates

C1 · Making one, and what OpenSSL quietly does for you

The analogy — printing your own passport at home.

You can produce a document that looks exactly like a passport. Same layout, same fields, your real photo, your real name. You can even stamp it — with your own stamp.

It is not a forgery of anyone else's document. It is a genuine document that says exactly what it says. The only problem is that no border post has agreed to accept your stamp.

That is a self-signed certificate. Structurally identical to a root CA certificate (Module 03, D3). The difference is entirely about who has agreed to trust it.

🧪 Exercise C1.1 — Make one the naive way, and look at what you got
bash
cd ~/tls-lab/m04

openssl req -x509 -key server.key -out plain.crt -days 365 -subj "/CN=test.local"

echo "=== subject and issuer ==="
openssl x509 -in plain.crt -noout -subject -issuer

echo
echo "=== what extensions did OpenSSL add without being asked? ==="
openssl x509 -in plain.crt -noout -text | sed -n '/X509v3 extensions/,/Signature Algorithm/p'
Expected result — click to reveal
plain text
=== subject and issuer ===
subject=CN = test.local
issuer=CN = test.local

=== what extensions did OpenSSL add without being asked? ===
        X509v3 extensions:
            X509v3 Subject Key Identifier:
                97:10:76:20:D7:72:1E:6D:47:B8:D6:BF:73:4E:E4:05:2F:4D:4A:1D
            X509v3 Authority Key Identifier:
                97:10:76:20:D7:72:1E:6D:47:B8:D6:BF:73:4E:E4:05:2F:4D:4A:1D
            X509v3 Basic Constraints: critical
                CA:TRUE
    Signature Algorithm: sha256WithRSAEncryption

What to read out of this — there are three genuine surprises here.

1. Subject equals Issuer. Exactly the signature of a self-signed certificate from Module 03 (B1.1 and D3). It signed itself with server.key.

2. SKI and AKI are the same twenty bytes. Of course they are — AKI points at the issuer's key, and the issuer is itself. Module 03 (C4) said AKI and SKI link a certificate to its parent; here the certificate is its own parent.

3. Basic Constraints: critical, CA:TRUE — and you never asked for that. This is the one that catches people. OpenSSL's default configuration applies a v3_ca extension section when you use -x509, so the "server certificate" you just made is marked as a Certificate Authority.

And now the most important observation: there is no SAN. No subjectAltName anywhere. Combined with Module 03's verified client table, this certificate will be:

  • Accepted by openssl verify -verify_hostname, curl, Python and Node — they fall back to the CN
  • Rejected by every browser, and by Go

🔑 So the default openssl req -x509 produces a certificate that is wrong in two ways at once — it claims to be a CA, and it has no SAN — and every command-line tool you would naturally reach for will tell you it is fine. This is precisely why so many internal test certificates "work on my machine" and fail the moment anyone opens a browser.

🎯 Interview questions — Self-signed basics

Q. Walk me through creating a self-signed certificate with OpenSSL.

The minimal command is one line:

bash
openssl req -x509 -newkey rsa:2048 -noenc -keyout k.key -out c.crt -days 365 -subj "/CN=test.local"

But that command produces a certificate with two defects, and knowing them is the actual answer:

  1. No SAN, so browsers and Go reject it — while curl and openssl accept it, which hides the problem.
  2. basicConstraints: CA:TRUE, because OpenSSL's default -x509 profile marks it as a CA. You have made a CA, not a server certificate.

The version worth actually using adds the extensions explicitly:

bash
openssl req -x509 -newkey rsa:2048 -noenc -keyout k.key -out c.crt -days 365 \
  -subj "/CN=test.local" \
  -addext "subjectAltName=DNS:test.local,DNS:localhost,IP:127.0.0.1" \
  -addext "basicConstraints=critical,CA:FALSE" \
  -addext "keyUsage=critical,digitalSignature,keyEncipherment" \
  -addext "extendedKeyUsage=serverAuth"

The judgement to show: for anything beyond a throwaway local test, a self-signed certificate is the wrong shape. Scattering individually self-signed certificates across services means every client must trust every certificate separately. One small private CA, with its root distributed once, is barely more work and gives you rotation and revocation. That is Module 05.


C2 · Why it is rejected — and by exactly whom

The analogy — presenting the home-made passport.

The officer does not say "this is fake". They say: "I do not have your stamp on my list."

It is not an accusation. It is an administrative fact. And there are two separate ways to change it: get on the list, or persuade this one officer to make an exception for you today.

Those two options are --cacert and -k, and confusing them is a real security problem.

🧪 Exercise C2.1 — Watch verification fail, then make it pass
bash
cd ~/tls-lab/m04

echo "=== 1. against the system trust store ==="
openssl verify plain.crt

echo
echo "=== 2. telling openssl to trust this exact certificate ==="
openssl verify -CAfile plain.crt plain.crt
Expected result — click to reveal
plain text
=== 1. against the system trust store ===
CN = test.local
error 18 at 0 depth lookup: self-signed certificate
error plain.crt: verification failed

=== 2. telling openssl to trust this exact certificate ===
plain.crt: OK

What to read out of this.

  • Error 18 — self-signed certificate. Add this to the verify-code table from Module 03 (B2.1). Code 18 is a self-signed leaf; code 19 is a self-signed certificate in a chain, which means an untrusted root above a real chain. Different problems, adjacent numbers.
  • The certificate did not change between the two commands. The only thing that changed is what OpenSSL was told to trust. That is Module 03's D3 lesson made operational: trust is a property of the trust store, not of the file.
  • -CAfile plain.crt plain.crt looks odd and is correct. You are saying "treat this file as a trusted root, then verify this file against it". A self-signed certificate is its own root, so the same file appears twice.
🧪 Exercise C2.2 — Serve it for real and see what each client says

Open two terminals for this one. It is worth doing properly, because these are the exact error strings you will meet.

bash
# --- terminal 1: run a server ---
cd ~/tls-lab/m04
openssl s_server -cert plain.crt -key server.key -accept 4433 -www

# --- terminal 2: three different clients ---
cd ~/tls-lab/m04

# a) no trust configured
curl -sS https://127.0.0.1:4433/ -o /dev/null

# b) trusting the certificate explicitly
curl -sS --cacert plain.crt https://127.0.0.1:4433/ -o /dev/null -w 'HTTP %{http_code}\n'

# c) skipping verification entirely
curl -sSk https://127.0.0.1:4433/ -o /dev/null -w 'HTTP %{http_code} (with -k)\n'
Expected result — click to reveal
plain text
a) curl: (60) SSL certificate problem: self-signed certificate
   More details here: https://curl.se/docs/sslcerts.html

b) HTTP 200

c) HTTP 200 (with -k)

What to read out of this — and (b) versus (c) is the whole point.

  • (a) curl: (60) is the same error 18 you saw from openssl verify, wearing curl's clothes. Exit code 60 is curl's "peer certificate cannot be authenticated".
  • (b) and (c) both return HTTP 200, and they are completely different things.

--cacert adds trust. You told curl "this specific certificate is a trusted root". Everything else is still checked — the signature, the dates, the hostname. If the server presented a different self-signed certificate, this would still fail. Security is intact; you have just extended trust deliberately.

-k removes checking. curl now accepts any certificate from anyone. An attacker on the path can present a certificate they generated ten seconds ago and curl will take it. You have turned TLS into encryption without authentication — which, as Module 01 (Part C1) showed, defends against nothing that matters.

🔑 -k in a script is a security bug, and it is worth saying so plainly in an interview. Its equivalents are everywhere: curl -k, wget --no-check-certificate, verify=False in Python requests, rejectUnauthorized: false in Node, InsecureSkipVerify: true in Go, -Djavax.net.ssl.trustStore pointed at nothing useful in Java. Every one of them turns off the only part of TLS that stops an active attacker.

The legitimate fix is always the same shape: give the client the CA certificate, either with a flag like --cacert or by installing it in the trust store. It is one extra file and it keeps every other check working.

💡 If you cannot open two terminals, run the server in the background instead:

bash
(openssl s_server -cert plain.crt -key server.key -accept 4433 -www >/dev/null 2>&1 &)
sleep 1
# ... run the curl commands ...
pkill -f 'openssl s_server'

Note that s_server -www serves one request per connection, so restart it between tests if a connection resets.

🎯 Interview questions — Trusting a self-signed certificate

Q. What is a self-signed certificate — advantages and disadvantages?

One where Subject equals Issuer: it was signed with its own private key, so it vouches for itself with no third party involved.

Advantages: free, instant, offline, no rate limits, no public Certificate Transparency record. Fine for local development, throwaway test environments, and as the root of a private CA you control.

Disadvantages: nothing trusts it by default, so every client needs configuring — and as Module 02 showed, "every client" means the OS store plus Java's cacerts plus Node plus Python's certifi plus Firefox, each maintained separately. It gives encryption but not identity, so it does not defend against the man-in-the-middle attack certificates exist to prevent.

The precision worth showing: a root CA certificate is also self-signed. The difference is not in the file — it is that vendors audited the CA and shipped its root in their trust stores.

And the practical steer: if you need internal certificates, do not scatter self-signed certificates across services. Run one private CA, distribute one root, issue leaves from it. One trust decision instead of hundreds, and rotation and revocation become possible.

Q. A developer's script uses curl -k. What do you tell them?

That -k disables certificate verification entirely, so the script will accept a certificate from anybody — including an attacker on the network path. It does not weaken TLS slightly; it removes the authentication half of it completely, leaving encryption to an unknown party.

The fix is almost always trivial: pass the CA certificate with --cacert ca.crt, or install the internal root into the system trust store. Trust is extended deliberately, and expiry, hostname and signature checks all keep working.

What to look for beyond the one line: -k is usually a symptom. Somebody hit a certificate error, needed the script working, and reached for the fastest fix. So the real questions are why the internal CA is not distributed to that host, or why the certificate has the wrong name. Removing -k without fixing the cause just moves the outage.

The wider point: the same footgun exists in every stack — verify=False, rejectUnauthorized: false, InsecureSkipVerify: true. Worth grepping a codebase for all of them, because they tend to arrive during an incident and never get removed.


C3 · A self-signed certificate that actually works

The analogy — filling in the boxes you skipped.

The home-made passport in C1 failed for two reasons that had nothing to do with the stamp. You left the "names covered" section blank, and you ticked the box marked "I am a passport-issuing office" without noticing it was pre-ticked.

Neither is fixed by getting a better stamp. Both are fixed by filling the form in properly.

That is what this section does: same self-signed certificate, same key, same command — four extra flags that say what the document actually is.

Everything wrong with plain.crt is fixable with four extra flags. This is the recipe to keep.

🧪 Exercise C3.1 — Build a correct one, and prove each fix
bash
cd ~/tls-lab/m04

openssl req -x509 -key server.key -out good.crt -days 365 \
  -subj "/CN=test.local" \
  -addext "subjectAltName=DNS:test.local,DNS:www.test.local,IP:127.0.0.1" \
  -addext "basicConstraints=critical,CA:FALSE" \
  -addext "keyUsage=critical,digitalSignature,keyEncipherment" \
  -addext "extendedKeyUsage=serverAuth"

openssl x509 -in good.crt -noout -text | sed -n '/X509v3 extensions/,/Signature Algorithm/p'

echo "=== hostname checks ==="
openssl verify -CAfile good.crt -verify_hostname test.local  good.crt
openssl verify -CAfile good.crt -verify_hostname other.local good.crt
Expected result — click to reveal
plain text
        X509v3 extensions:
            X509v3 Subject Key Identifier:
                97:10:76:20:D7:72:1E:6D:47:B8:D6:BF:73:4E:E4:05:2F:4D:4A:1D
            X509v3 Authority Key Identifier:
                97:10:76:20:D7:72:1E:6D:47:B8:D6:BF:73:4E:E4:05:2F:4D:4A:1D
            X509v3 Subject Alternative Name:
                DNS:test.local, DNS:www.test.local, IP Address:127.0.0.1
            X509v3 Basic Constraints: critical
                CA:FALSE
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment
            X509v3 Extended Key Usage:
                TLS Web Server Authentication
    Signature Algorithm: sha256WithRSAEncryption

=== hostname checks ===
good.crt: OK
CN = test.local
error 62 at 0 depth lookup: hostname mismatch

What to read out of this.

  • CA:FALSE overrode the default. Your -addext replaced OpenSSL's v3_ca default from C1.1. The certificate is now honestly an end-entity certificate.
  • SAN is present, with three entries and two types — two DNS: and one IP Address:. Including IP:127.0.0.1 is genuinely useful for local testing, because you can then connect by IP without a hostname mismatch.
  • Error 62 on other.local proves the SAN is being enforced. Compare this with running the same check against plain.crt, which returns OK for test.local purely through the CN fallback. Same tool, same command, and only the certificate with a real SAN is being checked properly.
  • keyEncipherment is included here alongside digitalSignature because this RSA certificate may serve TLS 1.2 clients that use an RSA key exchange (Module 03, C3). For an EC key you would drop it — ECDSA cannot encrypt at all.

🔑 The four-flag recipe worth saving as a snippet. For local development and internal testing this is the version to use, and it is the difference between a certificate that works everywhere and one that works only in curl:

bash
openssl req -x509 -newkey rsa:2048 -noenc -keyout dev.key -out dev.crt -days 365 \
  -subj "/CN=myservice.local" \
  -addext "subjectAltName=DNS:myservice.local,DNS:localhost,IP:127.0.0.1" \
  -addext "basicConstraints=critical,CA:FALSE" \
  -addext "keyUsage=critical,digitalSignature,keyEncipherment" \
  -addext "extendedKeyUsage=serverAuth"
🧪 Exercise C3.2 — Connect with a real client and confirm the name is checked
bash
cd ~/tls-lab/m04
(openssl s_server -cert good.crt -key server.key -accept 4433 -www >/dev/null 2>&1 &)
sleep 1

echo "=== connecting to 127.0.0.1, which IS in the SAN ==="
curl -sS --cacert good.crt https://127.0.0.1:4433/ -o /dev/null -w 'HTTP %{http_code}\n'

pkill -f 'openssl s_server'; sleep 1
(openssl s_server -cert good.crt -key server.key -accept 4433 -www >/dev/null 2>&1 &)
sleep 1

echo "=== connecting as a name that is NOT in the SAN ==="
curl -sS --cacert good.crt --resolve nope.local:4433:127.0.0.1 https://nope.local:4433/ -o /dev/null

pkill -f 'openssl s_server'
Expected result — click to reveal
plain text
=== connecting to 127.0.0.1, which IS in the SAN ===
HTTP 200

=== connecting as a name that is NOT in the SAN ===
curl: (60) SSL: no alternative certificate subject name matches target host name 'nope.local'

What to read out of this.

  • The first request succeeded because IP:127.0.0.1 is in the SAN. Remove that entry and the same request fails — connecting by IP requires an IP Address: SAN entry, not a DNS: one (Module 03, B3).
  • Read curl's second error carefully: no alternative certificate subject name matches. The words "alternative certificate subject name" are curl telling you it looked at the SAN. Compare it with the message you saw against a CN-only certificate in Module 03 — certificate subject name 'test.local' does not match — which has no "alternative" in it, because curl was looking at the CN.
  • That one word is a free diagnostic. If curl's error says "alternative", your certificate has a SAN and the name is simply not in it. If it does not, your certificate has no SAN at all and you have a bigger problem that browsers will reject outright.

💡 --resolve host:port:ip is worth knowing. It tells curl to pretend a hostname resolves to a given IP, without touching DNS or /etc/hosts. It is the cleanest way to test name-based TLS before you have changed any DNS, and it is invaluable when validating a certificate on a new server prior to cutover.

🎯 Interview questions — Making certificates work locally

Q. How would you give developers working HTTPS on their laptops?

Not with individually self-signed certificates per service — that means every developer trusting every certificate separately, and it breaks again on every regeneration.

The workable options, roughly in order of preference:

  1. A shared development CA. One root, distributed once through your device management or the dev environment setup, issuing per-service certificates. This is mkcert in a bag, and it is the standard answer.
  2. A real certificate for a real subdomain. Issue *.dev.example.com or per-developer names from your normal CA via DNS-01 challenges, and point them at 127.0.0.1. No trust configuration at all, because the certificates are publicly trusted.
  3. Terminate TLS in a local proxy — Caddy or Traefik with an internal CA — so developers never handle certificates directly.

The requirement people forget: the certificate needs IP:127.0.0.1 in its SAN as well as the hostname, or connecting by IP fails. And the trust store is per-runtime, so a root installed for the OS still will not satisfy Java or Node.

What to avoid saying: "developers can just use -k". That normalises disabling verification, and the habit follows people into production code.


Part D · Playing the CA yourself

You have made a request (Part A and B) and you have made a self-signed certificate (Part C). Part D joins them: you will take a CSR and turn it into a certificate, signing it with a key you control.

This is deliberately not a full certificate authority — no serial database, no revocation, no proper directory layout. That is Module 05. What this part does is let you stand where the CA stands, so that section A3's claim — "the CA ignores most of what you send" — stops being something you were told and becomes something you did.

It also contains the most instructive deliberate failure in the module.

D1 · Signing a CSR — and the flag that silently discards your SAN

The analogy — the clerk retyping your form.

The clerk does not photocopy your application into a passport. They retype the parts they are willing to vouch for, into their own document, in their own system.

And by default their system has no field for the "other names" box. Whatever you wrote there is simply not carried across. Not rejected, not queried — just not copied.

That is openssl x509 -req. It takes the public key and the subject, and throws away every extension in your CSR unless you explicitly ask it not to.

🧪 Exercise D1.1 — Sign a CSR and lose the SAN
bash
cd ~/tls-lab/m04

# A CA certificate to sign with. Reusing server.key as the CA key is fine for a lab,
# and is exactly the kind of thing Module 05 will teach you not to do in production.
openssl req -x509 -key server.key -out ca.crt -days 3650 \
  -subj "/CN=Zaeem Lab Root CA" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"

echo "=== the CSR going in DOES have a SAN ==="
openssl req -in san.csr -noout -text | grep -A1 'Subject Alternative Name'

echo
echo "=== sign it the obvious way ==="
openssl x509 -req -in san.csr -CA ca.crt -CAkey server.key -out leaf-nosan.crt -days 90

echo
echo "=== and the certificate coming out? ==="
openssl x509 -in leaf-nosan.crt -noout -subject -issuer -serial
openssl x509 -in leaf-nosan.crt -noout -ext subjectAltName
Expected result — click to reveal
plain text
=== the CSR going in DOES have a SAN ===
                X509v3 Subject Alternative Name:
                    DNS:shop.example.com, DNS:www.shop.example.com

=== sign it the obvious way ===
Certificate request self-signature ok
subject=CN = shop.example.com

=== and the certificate coming out? ===
subject=CN = shop.example.com
issuer=CN = Zaeem Lab Root CA
serial=12D4B1637DB4D906530702E6B99C46F5FAF93F25
No extensions in certificate

What to read out of this — this is the exercise that will save you the most time in real life.

  • The SAN went in and did not come out. No extensions in certificate. Two DNS: entries, carefully specified, silently discarded. No warning, no error, exit code 0.
  • This is the number one cause of "I definitely put the SAN in the CSR". People check the CSR, see the SAN, check the certificate, see nothing, and conclude the CA is broken. The CA is behaving exactly as designed.
  • Certificate request self-signature ok is OpenSSL verifying the CSR before signing it — the proof-of-possession check from Exercise A2.1, running automatically. Every CA does this first.
  • The subject came across, and the issuer is now the CA. Those two lines are the transformation: your claimed name plus someone else's signature.
  • The serial is a long random hex string you never supplied. OpenSSL 3.x generates a random serial automatically. Run the command twice and you get two different serials — which is the CA/B Forum's entropy requirement from Module 03 (A2) being satisfied by default.

⚠️ A small output oddity: openssl x509 -req prints subject=... itself as it works, so you will see the subject line twice. That first one is progress output, not part of the certificate.

🧪 Exercise D1.2 — Copy the extensions across, and then see why that is dangerous
bash
cd ~/tls-lab/m04

echo "=== sign again, this time copying extensions ==="
openssl x509 -req -in san.csr -CA ca.crt -CAkey server.key -out leaf.crt -days 90 \
  -copy_extensions=copy
openssl x509 -in leaf.crt -noout -ext subjectAltName

echo
echo "=== now a CSR that asks for something it should not have ==="
openssl req -new -key fresh.key -out evil.csr \
  -subj "/CN=totally-normal.example.com" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"

openssl req -in evil.csr -noout -text | sed -n '/Requested Extensions/,/Signature Alg/p'

echo "=== sign it with the same flag we just used ==="
openssl x509 -req -in evil.csr -CA ca.crt -CAkey server.key -out evil.crt -days 90 \
  -copy_extensions=copy
openssl x509 -in evil.crt -noout -ext basicConstraints,keyUsage
Expected result — click to reveal
plain text
=== sign again, this time copying extensions ===
Certificate request self-signature ok
X509v3 Subject Alternative Name:
    DNS:shop.example.com, DNS:www.shop.example.com

=== now a CSR that asks for something it should not have ===
            Requested Extensions:
                X509v3 Basic Constraints: critical
                    CA:TRUE
                X509v3 Key Usage: critical
                    Certificate Sign, CRL Sign

=== sign it with the same flag we just used ===
Certificate request self-signature ok
X509v3 Basic Constraints: critical
    CA:TRUE
X509v3 Key Usage: critical
    Certificate Sign, CRL Sign

Read the last four lines again, and take a moment with them.

You have just issued a Certificate Authority to whoever submitted that request. They asked to be a CA in their CSR, and -copy_extensions=copy granted it without a word. That certificate can now sign certificates for any domain in the world, and every one of them will chain back to your CA and be accepted by anything that trusts it.

Now the important bit: that flag is the one everybody adds.

The sequence is completely natural, and it is how this happens in real organisations:

  1. Someone signs a CSR and the SAN vanishes (Exercise D1.1).
  2. They search for why.
  3. Every answer says: add -copy_extensions=copy.
  4. They add it. The SAN appears. The problem is fixed.
  5. And now their internal CA grants any extension anybody requests.

🔑 So the default is not an inconvenience — it is the security control, and the same reasoning that made basicConstraints critical in Module 03 (C2). In 2002 a browser failed to check CA:TRUE. Here an issuer fails to control it. Both ends of the system have to hold, and this is the end you will actually be responsible for.

What to do instead. Never copy extensions blindly. Specify them yourself at signing time, so the issuer decides:

bash
cat > leaf-ext.cnf <<'EOF'
basicConstraints       = critical,CA:FALSE
keyUsage               = critical,digitalSignature,keyEncipherment
extendedKeyUsage       = serverAuth
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid,issuer
subjectAltName         = DNS:shop.example.com,DNS:www.shop.example.com
EOF

openssl x509 -req -in san.csr -CA ca.crt -CAkey server.key -out leaf-safe.crt \
  -days 90 -extfile leaf-ext.cnf

The SAN is now stated by you, the issuer, rather than accepted from the requester. This is exactly what a real CA does: it takes the public key, validates the names, and writes its own extensions from its own issuance profile.

💡 -copy_extensions=copyall also exists and is worse — it copies extensions OpenSSL would otherwise handle itself. If you ever genuinely need copying, copy is the safer of the two, and it still needs the CSR to come from somewhere you trust.

🧪 Exercise D1.3 — Verify the certificate you issued
bash
cd ~/tls-lab/m04

echo "=== does the leaf chain to our CA? ==="
openssl verify -CAfile ca.crt leaf.crt

echo "=== and does it match the hostname? ==="
openssl verify -CAfile ca.crt -verify_hostname shop.example.com leaf.crt

echo "=== the SAN-less one, checked the same way ==="
openssl verify -CAfile ca.crt leaf-nosan.crt
openssl verify -CAfile ca.crt -verify_hostname shop.example.com leaf-nosan.crt
Expected result — click to reveal
plain text
=== does the leaf chain to our CA? ===
leaf.crt: OK

=== and does it match the hostname? ===
leaf.crt: OK

=== the SAN-less one, checked the same way ===
leaf-nosan.crt: OK
leaf-nosan.crt: OK

What to read out of this — and the last line is the trap.

  • leaf.crt: OK twice. It chains to the CA, and shop.example.com is in its SAN. This certificate is genuinely correct.
  • leaf-nosan.crt also reports OK on the hostname check — and it has no SAN at all. OpenSSL fell back to the Common Name, exactly as Module 03's verified table said it would.
  • So openssl verify gave the SAN-less certificate a clean bill of health, and a browser would reject it outright. Two commands, indistinguishable results, one broken certificate.

🔑 This is the module's most practical warning. You cannot validate a certificate for browser use with openssl and curl alone, because both still implement the obsolete CN fallback. The reliable check is direct:

bash
openssl x509 -in cert.crt -noout -ext subjectAltName || echo "NO SAN - browsers will reject this"

Do not ask "does it verify?". Ask "does it have a SAN?" — and make that an explicit gate in any script that issues certificates.

🎯 Interview questions — Issuing certificates

Q. You put a SAN in your CSR but the issued certificate has none. What happened?

The signing step dropped it. CSR attributes are requests, and the issuer decides what to honour. With openssl x509 -req the default is to copy no extensions from the CSR at all — so unless the issuer passes -copy_extensions=copy or supplies its own -extfile, the SAN never reaches the certificate.

With a public CA the mechanism is different but the outcome is similar: the CA validates the names you requested and writes its own extensions from its issuance profile. Names you have not proven control of are dropped or the request is rejected.

The security point worth volunteering: the safe fix is -extfile, not -copy_extensions. Copying blindly means a CSR can request basicConstraints: CA:TRUE and be granted it — turning an ordinary server certificate into a CA that can issue for any domain. That default exists for a reason, and it is very commonly disabled by someone chasing a missing SAN.

Q. What is the risk of an internal CA that copies extensions from CSRs?

Anyone who can submit a CSR can request CA:TRUE with keyCertSign and receive a certificate that issues certificates for any name, chaining to your internal root — which is trusted on every machine in the estate.

That converts a low-privilege capability (requesting a server certificate, often self-service) into full authority over internal TLS: an attacker can impersonate any internal service, and every client accepts it.

The controls to name: never copy extensions from requests — apply a fixed issuance profile per certificate type. Set pathlen:0 on issuing intermediates so they cannot create CAs beneath them (Module 03, C2). Constrain intermediates with EKU. And use name constraints so an internal CA cannot issue for domains outside your own.

What this really demonstrates: the CSR's role is to carry a public key and a proof of possession. Every security-relevant field in the resulting certificate should be decided by the issuer, never accepted from the requester.


D2 · Closing the two loops from Module 02

Module 02 left two exercises deliberately unfinished, because you had no certificate of your own. Now you do.

🧪 Exercise D2.1 — The key ↔ certificate match, success case at last
bash
cd ~/tls-lab/m04

echo "=== the pair that SHOULD match ==="
diff <(openssl x509 -in leaf.crt -noout -pubkey) \
     <(openssl pkey  -in server.key -pubout) \
  && echo "MATCH - safe to deploy"

echo
echo "=== and a genuine mismatch ==="
diff <(openssl x509 -in leaf.crt -noout -pubkey) \
     <(openssl pkey  -in fresh.key -pubout) >/dev/null \
  && echo "MATCH" || echo "MISMATCH - this is what breaks nginx"
Expected result — click to reveal
plain text
=== the pair that SHOULD match ===
MATCH - safe to deploy

=== and a genuine mismatch ===
MISMATCH - this is what breaks nginx

What to read out of this.

  • This is Module 02's Exercise D2.1, finally with both outcomes. Back then you could only demonstrate a mismatch, because you had no certificate matching a key you owned.
  • Trace the public key's journey: it started in server.key, was copied into san.csr, and was copied again by the CA into leaf.crt. Three files, one public key, unchanged throughout. That is why comparing it works as an identity check across all three.
  • The mismatch case is exactly what produces SSL_CTX_use_PrivateKey_file ... key values mismatch in nginx, and it is invisible until the service restarts.

🔑 Make this a pre-deploy gate. Five lines, and it eliminates a whole class of 3am incident:

bash
if ! diff -q <(openssl x509 -in "$CERT" -noout -pubkey) \
             <(openssl pkey -in "$KEY" -pubout) >/dev/null; then
  echo "FATAL: $CERT and $KEY are not a pair - refusing to deploy" >&2
  exit 1
fi
🧪 Exercise D2.2 — Build a real PKCS#12, with a key in it
bash
cd ~/tls-lab/m04

openssl pkcs12 -export \
  -inkey server.key \
  -in leaf.crt \
  -certfile ca.crt \
  -name "shop-server" \
  -out bundle.p12 -passout pass:changeit

openssl pkcs12 -info -in bundle.p12 -passin pass:changeit -noout
Expected result — click to reveal
plain text
MAC: sha256, Iteration 2048
MAC length: 32, salt length: 8
PKCS7 Encrypted data: PBES2, PBKDF2, AES-256-CBC, Iteration 2048, PRF hmacWithSHA256
Certificate bag
Certificate bag
PKCS7 Data
Shrouded Keybag: PBES2, PBKDF2, AES-256-CBC, Iteration 2048, PRF hmacWithSHA256

What to read out of this.

  • Shrouded Keybag is the new line, and it is the whole point. In Module 02 (C2.1) you built a truststore and saw only Certificate bag entries. "Shrouded" means the private key inside is encrypted with the file's password.
  • Two Certificate bag entries — the leaf and the CA certificate you passed with -certfile. This is the shape of a real deployment bundle: identity plus the chain above it.
  • This file now contains a private key. Everything Module 02 said applies: treat it exactly like a bare key, and remember the iteration count of 2048 is low enough that a weak password is crackable offline.

💡 To unpack it again — and note -noenc, without which the extracted key comes out encrypted and your web server cannot read it (Module 02, D1):

bash
openssl pkcs12 -in bundle.p12 -passin pass:changeit -nocerts -noenc -out out.key
chmod 600 out.key
openssl pkcs12 -in bundle.p12 -passin pass:changeit -clcerts -nokeys -out out.crt

D3 · The whole lifecycle, end to end

Diagram source
flowchart TD
    K["🔑 1. PRIVATE KEY<br>generated ON the target host<br>never leaves it"]
    K --> R["📝 2. CSR<br>subject + public key + SAN<br>signed by your own key"]
    R --> V{"3. CA validates<br>do you control<br>these names?"}
    V -->|"no"| X["❌ rejected"]
    V -->|"yes"| I["🏢 4. CA ISSUES<br>keeps: public key + validated names<br>discards: O, OU, requested extensions<br>adds: serial, dates, issuer, AIA, CRL, SCTs"]
    I --> C["📜 5. CERTIFICATE + CHAIN"]
    C --> D["🚀 6. DEPLOY<br>fullchain.pem + the SAME key<br>then RELOAD the service"]
    D --> M["📡 7. VERIFY THE LIVE ENDPOINT<br>not the file on disk"]
    M -.->|"before expiry"| R
    style K fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
    style I fill:#e1d5e7,stroke:#9673a6,stroke-width:2px
    style D fill:#d5e8d4,stroke:#82b366,stroke-width:2px
    style X fill:#ffcccc,stroke:#cc0000,stroke-width:2px
Three things this diagram is trying to burn in:

The key never moves. It is generated at step 1 on the machine that will use it and stays there. Everything else travels. A key that has been emailed, pasted into a ticket, or downloaded from a vendor portal has been somewhere you cannot account for.

Step 4 is a transformation, not a copy. The certificate you receive is not your CSR with a signature added. It is a new document, built by the CA, that shares only your public key and the names it validated.

Step 6 has two halves and people do one of them. Writing the file is not deploying. The process is still holding the old certificate in memory until it reloads, which is why step 7 checks the live endpoint rather than the disk.

🧪 Exercise D3.1 — Which files do you keep, and which do you destroy?

Work it out before opening the answer. You now have a directory full of artefacts.

bash
cd ~/tls-lab/m04
ls -l *.key *.csr *.crt *.p12 2>/dev/null
Expected result — click to reveal
FileKeep?Why
Private keyKeep — and protectIrreplaceable. Lose it and the certificate is useless; leak it and everything is compromised. 0600, never in git
CertificateKeepNeeded to serve. Public — no protection required, and you can always re-download it from the CA or from CT logs
Chain / intermediatesKeepMust be served with the leaf, or non-browser clients fail (Module 03, C5)
CSRThrow away after issuanceIt has done its job. Trivially regenerated from the key if you need another one
.p12 bundleKeep only if something needs itIt contains the key. One more copy of your most sensitive material — delete it once imported

The reasoning worth internalising.

  • The CSR is disposable and people hoard it. You can rebuild an identical one from the key in one command. Keeping old CSRs around adds clutter and occasionally confusion about which one produced which certificate.
  • The certificate is public. It is in Certificate Transparency logs; anybody can fetch it from your server. Protecting it achieves nothing, and treating it as secret leads to strange workflows.
  • The key is the only irreplaceable thing here. Every other file in that listing can be recreated or re-downloaded.
  • .p12 files accumulate. They get created for an import, emailed, left in home directories and forgotten — each one a full copy of the private key behind a password that was probably weak. Delete them after use.

Now imagine this at 500 hosts. The failure mode is not dramatic, it is entropy: keys copied between hosts "temporarily", .p12 files in shared drives, CSRs and certificates in a ticket system, and eventually nobody knowing which key is live on which host. The structural fix is that keys are generated on the host and never travel — which is exactly what ACME clients do automatically, and one of the strongest arguments for automation beyond just avoiding expiry.


Part E · Putting it together

E1 · How this all fits — the complete picture

Diagram source
flowchart LR
    subgraph YOU["🧑 WHAT YOU CAN SAY"]
        Y1["your public key"]
        Y2["names you want"]
        Y3["extensions you would like"]
        Y4["signed with YOUR key<br>= proof of possession"]
    end
    subgraph CA["🏢 WHAT ONLY AN ISSUER CAN SAY"]
        C1["serial number"]
        C2["validity dates"]
        C3["issuer name"]
        C4["which extensions apply"]
        C5["signed with the ISSUER key<br>= the actual trust"]
    end
    YOU -->|"CSR"| CA
    CA -->|"CERTIFICATE"| OUT["📜 a document about you,<br>written by someone else"]
    style YOU fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
    style CA fill:#e1d5e7,stroke:#9673a6,stroke-width:2px
    style OUT fill:#d5e8d4,stroke:#82b366,stroke-width:2px

The line down the middle of that diagram is the whole module. Everything on the left is a claim. Everything on the right is a decision. A CSR is a claim; a certificate is a decision.

And a self-signed certificate is what happens when one party does both jobs — which is why it works perfectly and convinces nobody.


E2 · Production practice

HabitWhy
Always pass -addext "subjectAltName=DNS:..." when creating a CSRWithout it the certificate has no SAN, browsers reject it, and openssl/curl will not warn you
Generate the private key on the host that will use itA key that has travelled has been somewhere you cannot audit. This is also what ACME clients do by default
Use -key for renewals, and rotate keys as a separate deliberate stepAn accidental -newkey mid-renewal is the classic cause of key values mismatch after a restart
Gate deployments on the key↔certificate diff checkA mismatch is invisible until the service restarts, which may be days after the bad deploy
Never sign a CSR with -copy_extensions — use -extfileA CSR can request CA:TRUE, and copying grants it. The issuer must own every security-relevant extension
Check -ext subjectAltName explicitly after issuing, as a hard gateopenssl verify passes SAN-less certificates via the CN fallback. Verifying is not the same as being correct
Test certificates with a browser or a Go client, not just curlThis is the one case where the CLI is more permissive than the browser
Treat curl -k, verify=False, InsecureSkipVerify as bugs, and grep for themThey disable authentication entirely. They arrive during incidents and are never removed
Delete CSRs and .p12 files once they have served their purposeCSRs are regenerable clutter; .p12 files are extra copies of your private key behind a weak password
For internal TLS, run one small private CA rather than many self-signed certificatesOne trust decision instead of hundreds, and rotation and revocation become possible at all

E3 · Capstone exercise

Do this without looking anything up. It exercises everything in the module: key generation, CSR construction with SAN, proof of possession, signing with your own CA, extension control, verification, and the traps that make a wrong certificate look right.

Brief. Build a complete miniature PKI in one directory, and prove it works:

  1. Create a CA — self-signed, CA:TRUE, keyCertSign, 10 years, with its own key (not shared with any server)
  2. Create a server key and CSR for api.internal.test, with SAN covering api.internal.test, www.api.internal.test and 127.0.0.1
  3. Sign the CSR with your CA so the resulting certificate has the right SAN, CA:FALSE, serverAuth, and 90 days validity — without using -copy_extensions
  4. Prove: the chain verifies, the hostname matches, a wrong hostname fails, and the key matches the certificate
  5. Produce a deliberately broken second certificate with no SAN, and write the one command that catches it when openssl verify does not
  6. Serve the good certificate with s_server and connect with curl using --cacert, without using -k
Model answer — attempt it first, then click
bash
mkdir -p ~/tls-lab/m04/capstone && cd ~/tls-lab/m04/capstone
umask 077

# --- 1. the CA, with its OWN key ---
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out ca.key
openssl req -x509 -key ca.key -out ca.crt -days 3650 \
  -subj "/C=MY/O=Zaeem Labs/CN=Zaeem Labs Internal Root CA" \
  -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
  -addext "keyUsage=critical,keyCertSign,cRLSign" \
  -addext "subjectKeyIdentifier=hash"

# --- 2. the server key and CSR ---
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out api.key
openssl req -new -key api.key -out api.csr \
  -subj "/C=MY/O=Zaeem Labs/CN=api.internal.test" \
  -addext "subjectAltName=DNS:api.internal.test,DNS:www.api.internal.test,IP:127.0.0.1"

openssl req -in api.csr -noout -verify        # proof of possession

# --- 3. sign it, with the ISSUER stating the extensions ---
cat > api-ext.cnf <<'EOF'
basicConstraints       = critical,CA:FALSE
keyUsage               = critical,digitalSignature
extendedKeyUsage       = serverAuth
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid,issuer
subjectAltName         = DNS:api.internal.test,DNS:www.api.internal.test,IP:127.0.0.1
EOF

openssl x509 -req -in api.csr -CA ca.crt -CAkey ca.key -out api.crt \
  -days 90 -extfile api-ext.cnf

# --- 4. prove it ---
openssl verify -CAfile ca.crt api.crt
openssl verify -CAfile ca.crt -verify_hostname api.internal.test  api.crt   # OK
openssl verify -CAfile ca.crt -verify_hostname nope.internal.test api.crt   # error 62
diff <(openssl x509 -in api.crt -noout -pubkey) <(openssl pkey -in api.key -pubout) \
  && echo "KEY MATCHES CERTIFICATE"

# --- 5. the deliberately broken one ---
openssl req -new -key api.key -out bad.csr -subj "/CN=api.internal.test"
openssl x509 -req -in bad.csr -CA ca.crt -CAkey ca.key -out bad.crt -days 90

openssl verify -CAfile ca.crt -verify_hostname api.internal.test bad.crt   # says OK - and is WRONG
openssl x509 -in bad.crt -noout -ext subjectAltName \
  || echo "CAUGHT IT: no SAN - browsers and Go will reject this"

# --- 6. serve it and connect properly ---
cat api.crt ca.crt > fullchain.pem
(openssl s_server -cert fullchain.pem -key api.key -accept 4433 -www >/dev/null 2>&1 &)
sleep 1
curl -sS --cacert ca.crt https://127.0.0.1:4433/ -o /dev/null -w 'HTTP %{http_code}\n'
pkill -f 'openssl s_server'

Expected output at the key points:

plain text
Certificate request self-signature verify OK
Certificate request self-signature ok
api.crt: OK
api.crt: OK
CN = api.internal.test
error 62 at 0 depth lookup: hostname mismatch
KEY MATCHES CERTIFICATE
bad.crt: OK                                    <- openssl is happy. It should not be.
CAUGHT IT: no SAN - browsers and Go will reject this
HTTP 200

The six decisions this capstone is really testing:

1. The CA has its own key. Earlier in this module we reused server.key as both the CA key and the server key, which is fine for a throwaway demo and wrong everywhere else — it means compromising the web server compromises the CA. A separate, larger, longer-lived CA key is the point of having a CA at all.

2. pathlen:0 on the CA. Nothing beneath it may be a CA. Module 03 (C2), applied.

3. -extfile, not -copy_extensions. The issuer writes the extensions. Requirement 3 said so explicitly, because that is the security lesson of D1.2.

4. keyUsage = digitalSignature only, with no keyEncipherment — because the server key is EC, and ECDSA cannot encrypt anything (Module 03, C3). Adding keyEncipherment to an EC certificate is meaningless, and a reviewer who spots it knows you copied the RSA recipe without thinking.

5. fullchain.pem = leaf then CA. Order matters, and it is the same concatenation you met in Module 02 (D3).

6. Requirement 5 is the heart of it. openssl verify reports bad.crt: OK. It is not lying — by its own rules, with the CN fallback, that certificate matches. But it is a certificate no browser will accept. The only reliable test is to check for the SAN directly, and building that into your tooling is what this module is ultimately for.

What you have built here is a real, if minimal, private CA — and every shortcut in it is something Module 05 will replace: no serial database, no revocation list, no separation between root and issuing CA, and a CA key sitting on the same disk as everything else.


E4 · Official documentation — what to bookmark and how to read it

The single most useful page for this module: openssl req manual. It is the one command that generates keys, creates CSRs and creates self-signed certificates, which is why its flag list is confusing until you notice it is really three tools in a trench coat.

Make it a reflex: before copying a openssl req command from a blog post, check whether it includes -addext "subjectAltName=...". If it does not, the command is pre-2018 and will produce a certificate browsers reject.

Core reference pages

LinkWhat it is for
openssl req manualCSRs, key generation and -x509 self-signed certificates. The workhorse of this module
openssl x509-req and -copy_extensionsTurning a CSR into a certificate. Read the -copy_extensions warning properly
x509v3_config — extension syntaxThe exact spelling of every extension for -addext and -extfile. The page to keep open while writing them
config — OpenSSL config file formatSection syntax, @section references, and why prompt = no matters
RFC 2986 — PKCS #10What a CSR is, definitively. Short — about 10 pages — and worth actually reading
RFC 5280 §4.2 — extensions · RFC 9525 — hostname matchingWhat the extensions mean, and why SAN is the only field that counts
openssl verify · openssl s_serverOffline chain and hostname checking; running a throwaway TLS server for testing
OpenSSL Cookbook (free online)Task-oriented recipes for key and certificate creation. Faster than the man pages
curl — SSL certificate verificationThe page curl's error 60 points you at. Explains --cacert versus -k properly
CA/B Forum Baseline RequirementsWhat a public CA will and will not accept from your CSR

How to read an openssl req command

Every openssl req command is answering four questions. Once you can spot them, you can read any variant:

plain text
openssl req  -x509  -newkey rsa:2048 -noenc -keyout k.key  -out c.crt  -days 365 \
             -subj "/CN=host"  -addext "subjectAltName=DNS:host"
             |      |                                      |
    1. request or   2. new key, or -key for an existing one |
       certificate?                                         |
                              3. output file and lifetime   |
                                                            4. WHAT NAMES <- the part that matters
  1. -x509 present? Without it you get a CSR. With it you get a self-signed certificate. One flag, two completely different artefacts — and the commonest source of confusion in copied commands.
  2. -newkey or -key? New key, or reuse an existing one. This decides whether pins break.
  3. -days applies only with -x509, because a CSR has no validity period.
  4. -addext "subjectAltName=..." — if this is missing, the output is broken for browsers, and nothing will tell you.

The offline alternative

bash
openssl req -help                     # every flag, grouped
openssl x509 -help | grep -A2 copy_extensions   # read the warning
man x509v3_config                     # exact extension spelling, if docs are installed
openssl list -digest-algorithms       # what you can sign with
openssl req -new -key k.key -out /dev/null -text -noout   # dry run: build and print, keep nothing
🧪 Exercise E4.1 — Find the right flag without a browser

You need to know whether openssl x509 -req will carry your CSR's SAN across. Answer it from the CLI alone.

bash
openssl x509 -help 2>&1 | grep -B1 -A4 'copy_extensions'
Expected result — click to reveal
plain text
-copy_extensions val        copy extensions when converting from CSR to x509 or vice versa

What to read out of this.

  • The phrase "copy extensions when converting" tells you the answer by implication. If a flag exists to enable copying, then copying is not the default. That is the reasoning to practise — a flag named -copy_extensions would be pointless if extensions were copied anyway.
  • The one-line help does not warn you. The full manual page does, and it is worth reading the paragraph there: it explicitly notes the security risk of honouring extensions requested by an untrusted party.
  • val means it takes a value, not a bare toggle. The values are none, copy and copyall — which the short help does not tell you, and which is exactly when you go to the manual page.

💡 The general habit: when a CLI has a flag to turn something on, the answer to "does it do this by default?" is almost always no. That inference will save you a lot of documentation searches.


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 a CSR contain, and what does it deliberately not contain?

Contains: subject DN, public key, optional requested extensions (in practice, SAN), and a signature made with your own private key.

Does not contain: validity dates, serial number, issuer, AIA, CRL distribution points or SCTs — every one of which is a statement only the issuer can make.

The one-line version: a CSR is what you can say about yourself; a certificate is what someone else says about you.

2. Why is a CSR signed if the CA does not know your key yet?

Proof of possession, not proof of identity. It shows you hold the private key matching the public key in the request, and that the request was not altered in transit.

Without it, anyone could take your public key from your published certificate and request a certificate for a different domain using a key they do not control.

3. You put O=Global Bank PLC in a CSR. What reaches the certificate?

For a DV certificate from a public CA, nothing — it is discarded silently, because nobody verified it. For OV/EV, the CA's own verified organisation name appears instead. For a private CA, it usually survives, since private CAs commonly honour the whole subject DN.

Practical consequence: for DV, do not agonise over the DN. Put the effort into the SAN list.

4. Why does openssl req -new without -addext produce a broken certificate?

It creates a CSR with no subjectAltName, so the certificate has none. Browsers and Go reject SAN-less certificates outright; the CN has not been used for hostname matching since 2017.

The trap is that openssl verify, curl, Python and Node all still fall back to the CN, so every command-line test passes and only the browser fails.

5. What two things does the default openssl req -x509 get wrong?

It adds no SAN, and it sets basicConstraints: critical, CA:TRUE from OpenSSL's default v3_ca profile — so the "server certificate" you just made is marked as a Certificate Authority.

Fix both with -addext "subjectAltName=..." and -addext "basicConstraints=critical,CA:FALSE", plus keyUsage and extendedKeyUsage=serverAuth.

6. What is the difference between curl --cacert ca.crt and curl -k?

--cacert adds trust for one specific CA. Signature, dates and hostname are all still checked, so a different certificate would still be rejected.

-k removes checking. curl accepts any certificate from anyone, so an active attacker on the path succeeds. It converts TLS into encryption with an unknown party, which defends against nothing that matters.

7. You signed a CSR and the SAN vanished. Why, and what is the safe fix?

openssl x509 -req copies no CSR extensions by default. The unsafe fix is -copy_extensions=copy; the safe fix is -extfile, where the issuer states the extensions itself.

Copying is dangerous because a CSR can request basicConstraints: CA:TRUE with keyCertSign and be granted it — turning an ordinary server certificate into a CA that can issue for any name.

8. openssl verify -verify_hostname says OK. Is the certificate good?

Not necessarily. OpenSSL still implements the legacy CN fallback, so a certificate with no SAN and a matching CN passes — while browsers and Go reject it.

The reliable check is direct: openssl x509 -noout -ext subjectAltName, treated as a hard gate. Ask "does it have a SAN?", not "does it verify?".

9. -key versus -newkey — why does the choice matter at renewal?

-key reuses the existing key, so any public-key pin keeps working and nothing that trusted you needs updating. -newkey generates a fresh key, breaking every pin.

The operational risk is an accidental -newkey in a copied renewal command: the new certificate is deployed alongside the old key, and the service fails to start with key values mismatch — often days later, at the next restart.

10. Which artefacts do you keep after issuance, and which do you destroy?

Keep the private key (irreplaceable, 0600, never in git), the certificate and the chain. Destroy the CSR — it is regenerable in one command — and delete any .p12 once imported, because it is another full copy of the private key behind a usually weak password.

11. Where should the private key be generated, and why does it matter?

On the host that will use it, so it never travels. A key that has been emailed, pasted into a ticket, or downloaded from a vendor portal has been through systems you cannot audit, and copies persist in message archives and backups indefinitely.

This is also why ACME clients generate keys locally by default, and one of the stronger arguments for automation beyond simply avoiding expiry.


E6 · Command reference — everything from this module

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

Create a CSR

bash
# ⭐ the one to memorise - key + CSR + SAN, no passphrase
openssl req -new -newkey rsa:2048 -noenc -keyout server.key -out server.csr \
  -subj "/CN=shop.example.com" \
  -addext "subjectAltName=DNS:shop.example.com,DNS:www.shop.example.com"

openssl req -new -key server.key -out server.csr -subj "/CN=host"   # ⭐ from an existing key
openssl req -new -key server.key -out server.csr -config csr.cnf    # ⭐ from a config file
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc \
  -keyout ec.key -out ec.csr -subj "/CN=host"                       # EC instead of RSA

Inspect and verify a CSR

bash
openssl req -in server.csr -noout -text            # ⭐ everything
openssl req -in server.csr -noout -subject         # ⭐ the claimed name
openssl req -in server.csr -noout -verify          # ⭐ proof of possession check
openssl req -in server.csr -noout -pubkey          # ⭐ the public key, for diffing
openssl req -in server.csr -noout -text | sed -n '/Attributes/,/Signature Alg/p'   # ⭐ is the SAN there?
openssl asn1parse -in server.csr | head -6         # raw structure

Self-signed certificates

bash
# ⭐ the four-flag recipe - the one you should actually use
openssl req -x509 -newkey rsa:2048 -noenc -keyout dev.key -out dev.crt -days 365 \
  -subj "/CN=myservice.local" \
  -addext "subjectAltName=DNS:myservice.local,DNS:localhost,IP:127.0.0.1" \
  -addext "basicConstraints=critical,CA:FALSE" \
  -addext "keyUsage=critical,digitalSignature,keyEncipherment" \
  -addext "extendedKeyUsage=serverAuth"

openssl req -x509 -key k.key -out c.crt -days 365 -subj "/CN=host"   # the naive version - no SAN, CA:TRUE

Sign a CSR yourself

bash
openssl x509 -req -in s.csr -CA ca.crt -CAkey ca.key -out s.crt -days 90              # drops ALL extensions
openssl x509 -req -in s.csr -CA ca.crt -CAkey ca.key -out s.crt -days 90 \
  -extfile ext.cnf                                                                    # ⭐ SAFE - issuer decides
openssl x509 -req -in s.csr -CA ca.crt -CAkey ca.key -out s.crt -days 90 \
  -copy_extensions=copy                                                               # ⚠️ grants what the CSR asks for

Verify what you made

bash
openssl verify -CAfile ca.crt leaf.crt                                  # ⭐ does it chain?
openssl verify -CAfile ca.crt -verify_hostname host leaf.crt            # ⭐ does the name match?
openssl x509 -in leaf.crt -noout -ext subjectAltName || echo "NO SAN"   # ⭐ the check that actually matters
diff <(openssl x509 -in leaf.crt -noout -pubkey) \
     <(openssl pkey -in server.key -pubout) && echo MATCH               # ⭐ key ↔ cert pre-deploy gate

Test it with a real client

bash
openssl s_server -cert fullchain.pem -key server.key -accept 4433 -www   # ⭐ throwaway TLS server
curl -sS --cacert ca.crt https://127.0.0.1:4433/                         # ⭐ trust the CA properly
curl -sS --cacert ca.crt --resolve host:4433:127.0.0.1 https://host:4433/  # ⭐ test a name without DNS
curl -sSk https://127.0.0.1:4433/                                        # ⚠️ no verification at all

Package it up

bash
cat leaf.crt ca.crt > fullchain.pem                                      # ⭐ leaf first, then the chain
openssl pkcs12 -export -inkey server.key -in leaf.crt -certfile ca.crt \
  -name "my-server" -out bundle.p12                                      # ⭐ key + cert + chain
openssl pkcs12 -in bundle.p12 -nocerts -noenc -out out.key && chmod 600 out.key
The three-command check before any certificate leaves your hands. None of them changes anything, and together they catch every failure mode in this module:
bash
openssl x509 -in cert.crt -noout -ext subjectAltName || echo "FAIL: no SAN"
openssl x509 -in cert.crt -noout -ext basicConstraints                    # should say CA:FALSE
diff <(openssl x509 -in cert.crt -noout -pubkey) <(openssl pkey -in key.pem -pubout)

Does it cover the right names · is it honestly not a CA · does it belong with this key. Everything else the issuer decided for you.


Next — Module 05 · Chain of Trust & Running Your Own CA.

In Part D you played the CA for a single certificate, and every shortcut you took is something Module 05 fixes: no serial database, no revocation list, no separation between a root and an issuing intermediate, and a CA key sitting on the same disk as the web server's.

Module 05 covers what a chain actually is and how a client builds one, why the order of certificates in fullchain.pem matters, pathlen and name constraints, cross-signing and why it exists, how Linux trust stores work with update-ca-certificates and update-ca-trust, and how to run a private CA that you would not be embarrassed to explain in an interview.

Official reading ahead of it: RFC 5280 §6 — Certification Path Validation and openssl verify.

📚 Sources for the interview questions

Question selection was cross-referenced against publicly published 2026 SSL/TLS, OpenSSL and PKI interview question sets, then rewritten and deepened:

Every command and every expected output in this module was executed on OpenSSL 3.0.13 rather than written from memory, including the client-behaviour findings: that openssl x509 -req silently discards CSR extensions, that -copy_extensions=copy grants a CSR's request for CA:TRUE, that the default openssl req -x509 produces a CA:TRUE certificate with no SAN, and that openssl verify -verify_hostname, curl, Python and Node all still accept a SAN-less certificate via the Common Name fallback while Go rejects it with certificate relies on legacy Common Name field, use SANs instead.

Standards claims were verified against primary sources: RFC 2986, RFC 5280, RFC 9525, RFC 7292, the CA/B Forum Baseline Requirements and the OpenSSL 3.x manual pages.

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.