# OpenSSL: TLS Certificate Verification and Parsing in CLI

LLMS index: [llms.txt](/en/llms.txt)

---

Certificates expiring on prod at the worst moment — a familiar story. OpenSSL answers TLS certificate questions faster than any marketplace checker. Here are the key scenarios without the fluff.

## Basic Certificate Parsing

The first command for any diagnostics is the text dump:

```bash
openssl x509 -text -noout -in cert.pem
```

Output shows Subject, Issuer, validity dates, signature algorithm, and public key. For a quick summary without the wall of text:

```bash
# Subject only
openssl x509 -noout -subject -in cert.pem

# Issuer only
openssl x509 -noout -issuer -in cert.pem

# Fingerprint only (SHA-256)
openssl x509 -noout -fingerprint -sha256 -in cert.pem
```

The `-in` flag accepts a file path. Certificates downloaded from browsers usually come in PEM or DER format. OpenSSL handles both, but DER requires an extra flag:

```bash
openssl x509 -inform DER -in cert.der -text -noout
```

> [!NOTE]
> `-noout` suppresses the base64 block from output. Useful when you need only structured data, not a copy of the certificate.

## Expiration: dates and Overdue Checks

For monitoring, getting only the dates is more convenient:

```bash
openssl x509 -noout -dates -in cert.pem
```

Typical output:

```
notBefore=Jan 15 00:00:00 2024 GMT
notAfter=Jan 14 23:59:59 2025 GMT
```

For automation, extracting the timestamp and calculating the difference is cleaner:

```bash
# Days remaining until expiry
not_after=$(openssl x509 -noout -enddate -in cert.pem | cut -d= -f2)
days_left=$(( ($(date -d "$not_after" +%s) - $(date +%s)) / 86400 ))
echo "$days_left days left"
```

If `days_left` is negative, the certificate has already expired.

> [!TIP]
> For checking multiple hosts from inventory, a one-liner works well:

```bash
for host in api.example.com admin.example.com; do
  echo -n "$host: "
  echo | openssl s_client -servername "$host" -connect "$host":443 2>/dev/null \
    | openssl x509 -noout -enddate
done
```

`-servername` sends SNI — without it some hosts return the default certificate.

## Chain Verification: s_client and verify

Connect and display certificates:

```bash
openssl s_client -connect example.com:443 -showcerts </dev/null
```

Output includes the chain from leaf certificate to root CA. To filter certificates only:

```bash
openssl s_client -connect example.com:443 -showcerts </dev/null \
  | awk '/-----BEGIN/,/-----END/{if(/-----BEGIN/)a=1;a;if(/-----END/)a=0}' > chain.pem
```

Verify chain against the system store:

```bash
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt chain.pem
```

If verification fails with `error 20 at 0 depth lookup`, the intermediate CA is missing. A common cause is incorrect chain configuration on the server.

> [!WARNING]
> `openssl verify` uses the system store by default. In Ubuntu this is `/etc/ssl/certs/ca-certificates.crt`, in Alpine it is a separate `ca-certificates` package. If verification fails, check that the package is installed.

Quick check without saving to file:

```bash
echo | openssl s_client -connect example.com:443 2>/dev/null \
  | openssl verify
```

Output `Verify return code: 0 (ok)` means success.

To stay out of interactive mode and fail the process on a bad chain:

```bash
openssl s_client -connect example.com:443 -servername example.com \
  -quiet -verify_return_error </dev/null
```

Force a protocol or cipher when you need to confirm the server still accepts a specific handshake:

```bash
openssl s_client -connect example.com:443 -servername example.com \
  -tls1_2 -cipher ECDHE-RSA-AES256-GCM-SHA384 -quiet </dev/null
```

For an internal CA, pass the bundle explicitly. Without `-CAfile`, a private PKI typically returns `Verify return code: 21 (unable to get local issuer certificate)`:

```bash
openssl s_client -connect example.com:443 -servername example.com \
  -CAfile /etc/ssl/certs/ca-bundle.crt -verify_return_error -quiet </dev/null
```

## Extracting CN and SAN

**Common Name** extracts directly:

```bash
openssl x509 -noout -subject -in cert.pem | grep -oP '(?<=CN = )[^,]+'
```

But CN has not been sufficient for years — modern certificates use **Subject Alternative Names (SAN)**. OpenSSL 1.1.1+ pulls them cleanly:

```bash
openssl x509 -noout -ext subjectAltName -in cert.pem
```

Output:

```
X509v3 Subject Alternative Name:
    DNS:example.com, DNS:www.example.com, DNS:api.example.com, IP:192.0.2.1
```

To get only the DNS names:

```bash
openssl x509 -noout -ext subjectAltName -in cert.pem \
  | grep -oP '(?<=DNS:)[^,]+'
```

> [!NOTE]
> If SAN is missing (old certificate), browsers fall back to CN. When checking API endpoints, this explains why `curl` complains but the browser opens the page.

## Comparing Expiration Across Multiple Hosts

A script for checking a host list serves as a practical monitoring foundation:

```bash
#!/bin/bash
# check-certs.sh — check certificate expiration dates

check_host() {
  local host=$1
  local port=${2:-443}
  
  echo | timeout 5 openssl s_client -servername "$host" -connect "$host:$port" 2>/dev/null \
    | openssl x509 -noout -enddate 2>/dev/null \
    | cut -d= -f2 \
    | while read date; do
        ts=$(date -d "$date" +%s)
        now=$(date +%s)
        days=$(( (ts - now) / 86400 ))
        printf "%-30s %3d days  %s\n" "$host" "$days" "$date"
      done
}

# Example usage
for h in api.example.com admin.example.com legacy.internal; do
  check_host "$h"
done
```

Typical output:

```
api.example.com                   45 days  Jan 14 23:59:59 2025 GMT
admin.example.com               -12 days  Dec  1 23:59:59 2024 GMT
legacy.internal                -120 days  Aug  5 23:59:59 2024 GMT
```

Negative values are expired certificates. In production, wrapping this in cron with chat notifications at a 30-day threshold is practical.

## Quick Flag Reference

| Command | Flag | Purpose |
|---------|------|---------|
| `x509` | `-text` | Full text dump |
| `x509` | `-noout` | Suppress base64 block |
| `x509` | `-dates` | NotBefore, NotAfter |
| `x509` | `-subject` | Subject (CN, O, OU) |
| `x509` | `-issuer` | Issuing CA |
| `x509` | `-fingerprint -sha256` | Certificate fingerprint |
| `x509` | `-enddate` | Expiration date only |
| `x509` | `-ext subjectAltName` | Alternative names |
| `s_client` | `-connect host:port` | TLS connection |
| `s_client` | `-servername name` | SNI (required for vhost) |
| `s_client` | `-showcerts` | Display full chain |
| `s_client` | `-quiet` | Skip interactive mode |
| `s_client` | `-tls1_2` / `-tls1_3` | Force a TLS version |
| `s_client` | `-verify_return_error` | Non-zero exit on validation failure |
| `verify` | `-CAfile path` | Trusted CA file |
| `verify` | `-partial_chain` | Accept partial chain |

> [!TIP]
> `openssl s_client` does more than read certificates. With `-starttls smtp` or `-starttls pop3` it checks mail servers. `-http` retrieves HTTP headers over TLS. Useful for diagnosing miTM filters.

All commands work out of the box in any Linux distribution. No dependencies beyond OpenSSL itself — the tool is present on every server. If missing, install in seconds: `apt install openssl` or `apk add openssl`.
