The certificate arrived on a Friday. It installed cleanly, the chain validated, the expiry was two years out — and every browser hitting the site threw NET::ERR_CERT_COMMON_NAME_INVALID.
The CSR had been generated from a config that set only the Common Name. No subjectAltName. The CA signed it because there was nothing structurally wrong with the request, and the resulting certificate was, by the letter of X.509, a valid certificate for nothing at all. Browsers stopped reading Common Name for hostname matching years ago. The name that mattered had never been requested.
That failure is invisible at every step until a browser refuses the connection. The CSR looked fine. openssl req -noout -text printed the Subject you expected. The CA accepted it. The certificate was real.
This guide covers the checks that actually decide whether a signing request survives contact with a CA, why some of them are inherited from CA/Browser Forum policy rather than from any RFC, and how to run all of them before you pay for anything.
What is actually inside a CSR
A PKCS#10 certification request (RFC 2986) is three DER-encoded fields, and only the first one carries anything you chose:
| Field | Contents |
|---|---|
CertificationRequestInfo | Version, Subject DN, SubjectPublicKeyInfo, and an optional attribute set |
signatureAlgorithm | The algorithm identifier used to sign the block above |
signature | The signature itself, produced with the private key matching the embedded public key |
Everything interesting hides in that optional attribute set. The SAN list, the key usage flags, the extended key usage flags — none of them are top-level CSR fields. They arrive wrapped inside a single PKCS#9 attribute, extensionRequest (OID 1.2.840.113549.1.9.14), which holds an X.509 Extensions sequence verbatim. A tool that only prints the Subject will happily tell you nothing is wrong with a CSR that requests no names whatsoever.
What a CSR never contains is the private key. Nothing in the request can be used to impersonate you, which is why mailing one to a CA is safe. The one field worth guarding is challengePassword — a shared secret some CAs accept as authorisation for later revocation. It is not a password for the key, and a decoder that prints its value on screen is leaking a revocation credential.
The seven checks a CA will run
Four of these come from the CA/Browser Forum Baseline Requirements rather than from any RFC, which is why a CSR can be perfectly well-formed and still be refused.
| Check | Rejected when | Source |
|---|---|---|
| Self-signature verifies | The signature does not match the embedded public key | RFC 2986 proof of possession |
| Key strength | RSA under 2048 bits, or an EC curve under 256 bits | BR 6.1.5 |
| Signature hash | SHA-1 or MD5 | BR 7.1.3.2 |
subjectAltName present | The request asks for no names at all | BR 7.1.4.2.1 |
| CN appears in the SAN list | Common Name names a host the SAN set does not cover | Browser behaviour, not the BR |
| Wildcard placement | * is anything other than the entire leftmost label | BR 3.2.2.6 |
| Names are publicly resolvable | .local, .internal, single-label hosts, private IP ranges | BR 7.1.4.2.1 |
The fifth row is the one that produced the Friday outage above, and it is the only one no CA is obliged to catch. Issuing a certificate whose CN is absent from the SAN list breaks nothing in the CA’s own validation — it is your problem, discovered by your users.
Verifying the self-signature yourself
A PKCS#10 request is signed by the private key that matches the public key inside it. That signature is the cheapest complete integrity check available: it covers the entire CertificationRequestInfo block, so a single flipped byte anywhere in the Subject, the public key, or the requested extensions invalidates it.
openssl req -in request.pem -verify -noout
# Certificate request self-signature verify OK
When that fails, the CSR was damaged rather than misconfigured. In practice the causes are mundane: a PEM truncated by a copy-paste that missed the last line, an email client re-wrapping the base64, a ticketing system stripping what it thought was trailing whitespace, or someone hand-editing the Subject in a text editor and assuming the signature would follow along.
The same check runs in a browser through Web Crypto, because everything it needs is inside the request:
// spkiBytes: the SubjectPublicKeyInfo DER, sliced out of the request
// tbsBytes: the CertificationRequestInfo DER, tag and length included
// sigBytes: the BIT STRING payload, minus its unused-bits byte
const key = await crypto.subtle.importKey(
'spki',
spkiBytes,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['verify'],
);
const valid = await crypto.subtle.verify(
{ name: 'RSASSA-PKCS1-v1_5' },
key,
sigBytes,
tbsBytes,
);
Two details bite here. ECDSA signatures are carried in X.509 as a DER SEQUENCE of two INTEGERs, while Web Crypto expects a raw fixed-width r‖s buffer — 32 bytes each for P-256, 48 for P-384, 66 for P-521 — so the DER has to be unpacked and each component left-padded to the field width. And RSA-PSS parameters are all optional with RFC 4055 defaults of SHA-1 and a 20-byte salt, which means a PSS request that omits them is not using SHA-256 no matter what the rest of your pipeline assumes.
CN versus SAN, in the only order that matters
Common Name was never designed to hold a hostname. X.500 defined it as a human-readable label for a directory entry, and using it for DNS names was a convention that RFC 2818 tolerated in 2000 and RFC 6125 deprecated in 2011. Chrome dropped CN fallback entirely in version 58. Every current browser matches on subjectAltName alone.
The practical rule has two halves, and only the second one is commonly missed:
- Every hostname the certificate must protect has to be in the SAN list.
- If you set a Common Name at all, that exact name has to be in the SAN list too.
The second half exists because CAs copy the CN into the issued certificate, and a CN that is not also a SAN entry becomes a name that appears in the certificate, shows up in your monitoring, and protects nothing. Most tooling papers over this: openssl req with the -addext flag will not warn you, and neither will a CA’s web form.
# Correct: CN is repeated as the first SAN entry
openssl req -new -newkey rsa:2048 -nodes \
-keyout key.pem -out request.pem \
-subj "/C=US/O=Example Corp/CN=example.com" \
-addext "subjectAltName=DNS:example.com,DNS:www.example.com"
Reading the SAN list back out requires knowing where it lives — inside extensionRequest, not at the top level:
from cryptography import x509
from cryptography.x509.oid import ExtensionOID, NameOID
csr = x509.load_pem_x509_csr(open("request.pem", "rb").read())
cn = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
cn = cn[0].value if cn else None
try:
san = csr.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
names = san.value.get_values_for_type(x509.DNSName)
except x509.ExtensionNotFound:
names = []
print("signature valid:", csr.is_signature_valid)
print("SAN:", names)
if cn and cn not in names:
print(f"WARNING: CN {cn} is not in the SAN list")
csr.is_signature_valid is the proof-of-possession check, and ExtensionNotFound is the case that produced the outage — an empty names list is the loudest signal in the whole script.
What ACME does with your CSR
If certificates come from Let’s Encrypt or any other ACME CA, most of the Subject you carefully filled in is discarded. RFC 8555 finalisation takes a PKCS#10 request, but the ACME server derives the identifiers it will validate from the SAN list and the CN, and Let’s Encrypt ignores every other Subject attribute outright. Organisation, organisational unit, country, locality — none of them reach the issued certificate, because domain-validated certificates carry no organisation identity by definition.
Two consequences follow. The first is that a CSR built for an ACME flow needs exactly two things to be right: the public key and the SAN list. Everything else is decoration that gets stripped. The second is that the identifiers in the finalise request must be a subset of the ones already authorised in the order, or the server returns a malformed problem document rather than a certificate — which is the ACME equivalent of the CN-without-SAN failure, caught earlier and with a clearer message.
Most ACME clients generate the CSR themselves and never show it to you, which is the right default. The time you see one is when a client is configured to use a pre-generated CSR — common where the key lives in an HSM or where a compliance process requires the key to be generated on the target host. That is exactly the path where a hand-built CSR reaches a CA unreviewed.
# What an ACME CA will actually read out of the request
openssl req -in request.pem -noout -text \
| grep -A2 'Subject Alternative Name'
Wildcards and names that can never be issued
A wildcard is valid only as the entire leftmost label. *.example.com is fine. api.*.example.com is not, because the wildcard is not leftmost. w*.example.com is not, because the label is partially literal — CAs refuse partial-label wildcards even though RFC 6125 discusses them, and browsers would not match them reliably anyway.
*.example.com also does not match example.com itself, nor a.b.example.com. A wildcard covers exactly one label. Certificates that appear to cover both the apex and its subdomains list them separately in the SAN.
The names that can never be issued at all are a shorter list than people expect:
| Name shape | Why it cannot be signed |
|---|---|
server.local, db.internal, app.corp | Reserved or unregistrable suffixes with no owner to validate against |
intranet (no dot) | A single-label name cannot be domain-validated |
10.0.0.5, 192.168.1.1, 127.0.0.1 | Private and loopback ranges, unvalidatable by definition |
*.co.uk | A wildcard spanning a registry-level suffix |
Public CAs stopped issuing for internal names in November 2015, when the Baseline Requirements deadline hit. For internal hosts the answer is a private CA whose root you distribute yourself — the requests are still PKCS#10, and every check in this article except the publicly-resolvable one still applies.
Where the extensions get lost during generation
Almost every SAN-missing CSR in the wild comes from one of three generation paths, and each drops the extensions for a different reason.
The first is openssl req driven by a config file, where the extension block exists but is never referenced. req_extensions names the section OpenSSL copies into extensionRequest; x509_extensions names the one it uses when self-signing with -x509. Defining [ v3_req ] and forgetting the req_extensions = v3_req line produces a request with a perfect Subject and no extensions at all, silently.
[ req ]
distinguished_name = dn
req_extensions = v3_req # without this line the block below is dead config
prompt = no
[ dn ]
C = US
O = Example Corp
CN = example.com
[ v3_req ]
subjectAltName = @alt_names
[ alt_names ]
DNS.1 = example.com
DNS.2 = www.example.com
The second is mixing -addext with -config. OpenSSL 3 accepts both, and -addext wins for any extension it names — so a config that requests four SANs plus an -addext "subjectAltName=DNS:example.com" on the command line ships one SAN, not five.
The third is Windows certreq, where the INF file needs SANs spelled as a 2.5.29.17 extension string rather than a friendly key. A typo in that OID line is not an error; it is an extension that quietly does not exist.
All three failures share a signature: the request parses, the Subject is right, and the SAN card is empty. That is why a decoder that shows requested extensions separately from the Subject catches them in one glance.
Reusing a CSR at renewal
A CSR embeds a specific public key, so reusing one at renewal reuses the key pair. Some teams do this deliberately — a stable key keeps an SPKI pin valid across renewals, and a pinned deployment that rotates keys breaks every client holding the old pin until the backup pin takes over.
The cost is that key rotation stops happening. A key issued in 2019 and carried through six renewals has spent six years on disk across every host, backup, and machine image that ever held it, and its exposure window is the union of all of them. Nothing in the certificate reveals this: the validity dates restart on every renewal while the key does not.
The workable compromise is to rotate the key on a schedule you choose rather than one your renewal cadence imposes — generate a fresh CSR annually even if certificates renew every 90 days, and keep a backup pin in the pinning configuration so the rotation is not a flag day. If pinning is not in play, generate a new key every time; there is no upside to carrying the old one.
# Same key, new request — deliberate reuse
openssl req -new -key existing-key.pem -out renewal.pem \
-subj "/CN=example.com" -addext "subjectAltName=DNS:example.com"
# New key and new request — the default you want without pinning
openssl req -new -newkey rsa:2048 -nodes \
-keyout new-key.pem -out renewal.pem \
-subj "/CN=example.com" -addext "subjectAltName=DNS:example.com"
Confirming the certificate you got back
When a certificate arrives, one comparison proves it was issued from your request: the public key fingerprint. It depends only on the key, so it is identical in the CSR and in every certificate ever issued for that key pair.
# From the request
openssl req -in request.pem -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary | base64
# From the issued certificate — the two must match
openssl x509 -in certificate.pem -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary | base64
That value is the RFC 7469 SPKI pin, the same string public key pinning configurations use. A mismatch means the certificate was signed from a different request — usually an older CSR still sitting in the CA’s dashboard, which is exactly the kind of mistake that survives a renewal cycle unnoticed.
Why not paste it into the first decoder you find
Search for a CSR decoder and the first page of results is almost entirely certificate resellers. Their decoders are lead-generation pages, and the ones built as a server-side form receive whatever you paste — while a CSR is a document that names the domains you are about to protect, the legal entity behind them, and, occasionally, a revocation secret. None of that is catastrophic to disclose. All of it is unnecessary to disclose.
| Typical reseller decoder | ZeroTool CSR Decoder | |
|---|---|---|
| Where parsing happens | Server-side form post | Your browser |
| Self-signature verified | Rarely | Yes, via Web Crypto |
| Baseline Requirements checks | No | Seven, with the failure reason |
| SPKI pin output | No | RFC 7469 form |
| Account required | Sometimes | No |
The CSR Decoder parses the DER, verifies the signature, and runs all seven checks without a network request. When a certificate comes back, the SSL Certificate Decoder reads the issued X.509 the same way, and the RSA Key Pair Generator covers the key generation step for the cases where a browser-generated throwaway key is genuinely what you want.
Further reading
- RFC 2986 — PKCS #10 Certification Request Syntax
- RFC 5280 — X.509 certificate and CRL profile, including the extension definitions carried by
extensionRequest - RFC 6125 — hostname verification, and the deprecation of Common Name
- RFC 7469 — the SPKI pin format
- CA/Browser Forum Baseline Requirements — the policy the seven checks are drawn from
- MDN: SubtleCrypto.verify() — the browser-side verification API