CA Chain Check Check a server's TLS certificate chain

Using the API

CA Chain Check has a small JSON API. The web page uses the same API, so anything the page shows is also in the API response.

For every field and a way to try requests in the browser, see the interactive API reference (Swagger), or download the OpenAPI spec as openapi.json.

Check a server

Send a POST to https://this-server/api/check with a JSON body:

curl -s https://this-server/api/check \
  -H 'Content-Type: application/json' \
  -d '{"host": "www.example.edu", "port": 443}'
FieldRequiredMeaning
hostYesHostname or IP address. The hostname is sent as SNI. A URL also works; only its hostname is used. Put the port in port, not after the hostname.
portNoTCP port. Defaults to 443, or to the protocol's usual port when starttls names one.
starttlsNosmtp, imap, pop3, ftp, ldap, postgres, or none. See STARTTLS.
connectIPsNoList of IP addresses to connect to instead of looking up the hostname, like curl --resolve. The hostname is still sent as SNI. This server may turn this off; GET /api/config says whether you can use it. Sending more addresses than this server allows is an error.

Every IP address the hostname resolves to is tested on its own. The body must be JSON, under 4 KB, with no other fields.

STARTTLS

Mail, directory and database servers often start in plain text and switch to TLS when asked. That switch is called STARTTLS.

  • Automatic: on ports 21 (FTP), 25 and 587 (SMTP), 110 (POP3), 143 (IMAP), 389 (LDAP) and 5432 (PostgreSQL), the check tries TLS first. If the TLS handshake fails, it reconnects and tries STARTTLS for that protocol.
  • Named: "starttls": "smtp" uses STARTTLS straight away, on any port. Use this for servers on other ports, such as SMTP on 2525.
  • Off: "starttls": "none" uses TLS only, even on the ports above.

When STARTTLS was used, the result for that IP has tls.starttls set to the protocol name.

RSA and ECDSA certificates

Many servers have both an RSA and an ECDSA certificate. After the normal handshake, the check asks each IP for the other key type with a TLS 1.2 handshake. If the server has it, that chain is in results[].alternate, with its own tls, analysis and overall, and results[].overall is the worse of the two. Otherwise results[].keyTypeNote says why, for example that the server has only one certificate or only allows TLS 1.3. results[].keyType is the main leaf's key type.

curl -s https://this-server/api/check \
  -H 'Content-Type: application/json' \
  -d '{"host": "smtp.example.edu", "port": 587}'

Response

A successful check returns HTTP 200 with JSON like this (shortened):

{
  "host": "www.example.edu",
  "port": 443,
  "checkedAt": "2026-11-08T20:16:00Z",
  "resolution": { "source": "dns", "ips": ["192.0.2.10", "192.0.2.11"] },
  "results": [
    {
      "ip": "192.0.2.10",
      "tested": true,
      "overall": "pass",
      "tls": { "version": "TLS 1.3", "cipherSuite": "TLS_AES_128_GCM_SHA256", "sni": "www.example.edu", "handshakeMs": 42 },
      "analysis": {
        "overall": "pass",
        "checks": [ { "id": "order", "title": "Certificates in correct order", "status": "pass", "detail": "..." } ],
        "chain": [ { "index": 0, "subjectCN": "www.example.edu", "sha256": "...", "pem": "..." } ],
        "storeRoots": [ ... ],
        "paths": [ { "nodes": ["c0", "c1", "r0"], "terminal": "mozilla_root", "trusted": true, "best": true } ],
        "mermaid": "flowchart TD ..."
      }
    }
  ],
  "consistency": { "status": "pass", "detail": "..." },
  "trustStore": { "count": 118, "loadedAt": "..." }
}

Fields you will use most

FieldMeaning
results[].overallWorst status for that IP: pass, warn, fail or info.
results[].errorSet when the connection, STARTTLS or TLS handshake failed. There is no analysis then.
results[].denyReasonSet when this server's policy does not allow testing that IP, for example a private address.
results[].analysis.checks[]One entry per test, with id, status, detail and sometimes items (the certificates involved).
results[].analysis.chain[]The certificates in the order the server sent them, with subject, issuer, dates, fingerprints and PEM.
results[].analysis.storeRoots[]Roots from the trust store that complete a path but were not sent.
results[].analysis.paths[]Every path from the leaf to a root, listed by certificate id. terminal is mozilla_root (a root at least one store trusts), untrusted_root or incomplete.
consistencyWhether all tested IPs sent the same chain.

Check IDs

IDTest
mozilla_rootEach required trust store (Apple, Chrome, Microsoft, Mozilla by default) trusts at least one path. Warns when only some do. The result per store is in analysis.trustStores, and each path lists the stores that trust it in stores.
orderEach certificate is followed by its issuer.
validityCertificates on the path are within their dates.
extra_certsNo certificates that aren't needed.
pending_distrustNo CA on the path is scheduled to lose trust.
revocationNo certificate on the best path is revoked, according to its issuer's OCSP responder or CRL. Each certificate's answers are in chain[].revocation. This server may turn the check off.
ocsp_staplingIf the server staples an OCSP response, it is valid, current and says the leaf is good. Fails when the leaf requires stapling (Must-Staple) and there is no usable staple. Not stapling is fine otherwise. Details are in analysis.staple.
hostnameThe leaf certificate covers the hostname.
root_sentNote only: the server sends its root.

For example, to list the result for each IP with jq:

curl -s https://this-server/api/check \
  -H 'Content-Type: application/json' \
  -d '{"host": "www.example.edu"}' \
  | jq '.results[] | {ip, overall, error}'

Example scripts

Each script checks one host and prints a table with one row per IP address and certificate. A server with both an RSA and an ECDSA certificate gets a row for each. The table lists the checks that warned or failed, then the consistency check and an overall result: the worst IP result, or the consistency result if it warns or fails.

They use this server unless you set CACHECK_URL. The same scripts are in the examples folder of the source code. The exit code makes them usable in CI and monitoring: 0 pass, 1 warn, 2 fail, 3 the request failed. Run them as ./cacheck.sh HOST [PORT], for example:

$ ./cacheck.sh expired.badssl.com
IP              KEY  TLS      RESULT  PROBLEMS
104.154.89.105  rsa  TLS 1.2  fail    validity (fail), extra_certs (warn)

Consistency: info. Only one IP was tested.
Overall: fail

Bash and jq

Needs curl and jq. Save as cacheck.sh.

#!/usr/bin/env bash
# Usage: ./cacheck.sh HOST [PORT]
# Prints one row per IP and certificate. Exit code: 0 pass, 1 warn, 2 fail, 3 request failed.
set -euo pipefail
host=${1:?usage: cacheck.sh HOST [PORT]}
port=${2:-0}
api="${CACHECK_URL:-https://this-server}/api/check"

body=$(jq -n --arg host "$host" --argjson port "$port" '{host: $host, port: $port}')
out=$(curl -sS -w '\n%{http_code}' -H 'Content-Type: application/json' -d "$body" "$api") || exit 3
code=${out##*$'\n'}
resp=${out%$'\n'*}
if [[ $code != 200 ]]; then
  echo "HTTP $code: $(jq -r '.error' <<<"$resp" 2>/dev/null || echo "$resp")" >&2
  exit 3
fi

# The overall result is the worst IP result, or the consistency check if it warns or fails.
overall=$(jq -r '[.results[].overall, (.consistency.status // empty | select(. == "warn" or . == "fail"))]
  | map({"pass": 0, "info": 1, "warn": 2, "fail": 3}[.] // 0) | max
  | ["pass", "info", "warn", "fail"][.]' <<<"$resp")

jq -r --arg overall "$overall" '
  def problems: [.checks[]? | select(.status == "warn" or .status == "fail") | "\(.id) (\(.status))"] | join(", ");
  ( [["IP", "KEY", "TLS", "RESULT", "PROBLEMS"]]
    + [ .results[] as $r
        | ( [$r.ip, ($r.keyType // "-"), ($r.tls.version // "-"),
             ($r.analysis.overall // $r.overall), ($r.error // $r.denyReason // ($r.analysis | problems))] ),
          ( $r.alternate // empty
            | [$r.ip, .keyType, .tls.version, .overall, (.analysis | problems)] ) ]
  ) as $rows
  | [range(0; 4) as $i | $rows | map(.[$i] | length) | max] as $width
  | ( $rows[] | [range(0; 4) as $i | .[$i] + " " * ($width[$i] - (.[$i] | length) + 2)] + [.[4]] | add ),
    "",
    ( .consistency // empty | "Consistency: \(.status). \(.detail)" ),
    "Overall: \($overall)"
' <<<"$resp"

case $overall in
  fail) exit 2 ;;
  warn) exit 1 ;;
esac

PowerShell

Tested with PowerShell 7 (pwsh) on Linux. Save as cacheck.ps1.

#!/usr/bin/env pwsh
# Usage: ./cacheck.ps1 HOST [PORT]
# Prints one row per IP and certificate. Exit code: 0 pass, 1 warn, 2 fail, 3 request failed.
param(
    [Parameter(Mandatory = $true)][string]$HostName,
    [int]$Port = 0,
    [string]$Server = $(if ($env:CACHECK_URL) { $env:CACHECK_URL } else { 'https://this-server' })
)
$ErrorActionPreference = 'Stop'
$rank = @{ pass = 0; info = 1; warn = 2; fail = 3 }

function Get-Problems($analysis) {
    @($analysis.checks | Where-Object { $_.status -in 'warn', 'fail' } |
        ForEach-Object { "$($_.id) ($($_.status))" }) -join ', '
}

$body = @{ host = $HostName; port = $Port } | ConvertTo-Json
try {
    $r = Invoke-RestMethod -Method Post -Uri "$Server/api/check" -ContentType 'application/json' -Body $body -TimeoutSec 180
} catch {
    $msg = $_.Exception.Message
    if ($_.ErrorDetails.Message) {
        $msg = $_.ErrorDetails.Message
        try { $msg = ($msg | ConvertFrom-Json).error } catch { }
    }
    [Console]::Error.WriteLine("Request failed: $msg")
    exit 3
}

$rows = foreach ($ip in $r.results) {
    if ($ip.analysis) {
        $result = $ip.analysis.overall
        $problems = Get-Problems $ip.analysis
    } else {
        $result = $ip.overall
        $problems = "$($ip.error)$($ip.denyReason)"
    }
    [pscustomobject]@{ IP = $ip.ip; Key = $(if ($ip.keyType) { $ip.keyType } else { '-' }); TLS = $(if ($ip.tls) { $ip.tls.version } else { '-' }); Result = $result; Problems = $problems }
    if ($ip.alternate) {
        $alt = $ip.alternate
        [pscustomobject]@{ IP = $ip.ip; Key = $alt.keyType; TLS = $alt.tls.version; Result = $alt.overall; Problems = Get-Problems $alt.analysis }
    }
}
($rows | Format-Table -AutoSize -Wrap | Out-String -Width 200).Trim()
''

$overall = 'pass'
$statuses = @($r.results | ForEach-Object { $_.overall })
if ($r.consistency) {
    if ($r.consistency.status -in 'warn', 'fail') { $statuses += $r.consistency.status }
    "Consistency: $($r.consistency.status). $($r.consistency.detail)"
}
foreach ($s in $statuses) { if ($rank[$s] -gt $rank[$overall]) { $overall = $s } }
"Overall: $overall"
if ($overall -eq 'fail') { exit 2 }
if ($overall -eq 'warn') { exit 1 }
exit 0

Python

Needs Python 3.8 or later and nothing outside the standard library. Save as cacheck.py.

#!/usr/bin/env python3
"""Usage: ./cacheck.py HOST [PORT]

Prints one row per IP and certificate. Exit code: 0 pass, 1 warn, 2 fail, 3 request failed.
Needs Python 3.8 or later and nothing outside the standard library.
"""
import json
import os
import sys
import urllib.error
import urllib.request

RANK = {"pass": 0, "info": 1, "warn": 2, "fail": 3}


def problems(analysis):
    return ", ".join(
        f"{c['id']} ({c['status']})"
        for c in (analysis or {}).get("checks", [])
        if c["status"] in ("warn", "fail")
    )


def main():
    if len(sys.argv) < 2:
        sys.exit(__doc__)
    server = os.environ.get("CACHECK_URL", "https://this-server")
    body = {"host": sys.argv[1], "port": int(sys.argv[2]) if len(sys.argv) > 2 else 0}
    req = urllib.request.Request(
        f"{server}/api/check",
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json", "User-Agent": "cacheck-example"},
    )
    try:
        with urllib.request.urlopen(req, timeout=180) as resp:
            data = json.load(resp)
    except urllib.error.HTTPError as e:
        try:
            msg = json.load(e).get("error", "")
        except ValueError:
            msg = e.reason
        print(f"HTTP {e.code}: {msg}", file=sys.stderr)
        sys.exit(3)
    except urllib.error.URLError as e:
        print(f"Request failed: {e.reason}", file=sys.stderr)
        sys.exit(3)

    rows = [("IP", "KEY", "TLS", "RESULT", "PROBLEMS")]
    for r in data["results"]:
        analysis = r.get("analysis")
        rows.append((
            r["ip"],
            r.get("keyType") or "-",
            (r.get("tls") or {}).get("version", "-"),
            analysis["overall"] if analysis else r["overall"],
            problems(analysis) if analysis else r.get("error") or r.get("denyReason") or "",
        ))
        if alt := r.get("alternate"):
            rows.append((r["ip"], alt["keyType"], alt["tls"]["version"], alt["overall"], problems(alt["analysis"])))

    widths = [max(len(row[i]) for row in rows) for i in range(4)]
    for row in rows:
        print("  ".join(cell.ljust(w) for cell, w in zip(row, widths)) + "  " + row[4])

    statuses = [r["overall"] for r in data["results"]]
    if consistency := data.get("consistency"):
        if consistency["status"] in ("warn", "fail"):
            statuses.append(consistency["status"])
        print(f"\nConsistency: {consistency['status']}. {consistency['detail']}")
    overall = max(statuses, key=lambda s: RANK.get(s, 0))
    print(f"Overall: {overall}")
    sys.exit({"warn": 1, "fail": 2}.get(overall, 0))


if __name__ == "__main__":
    main()

Errors and limits

  • 400: the request was not valid, for example a bad hostname or port, a hostname that does not resolve, or connectIPs when you may not use it. The body is {"error": "..."}.
  • 429: too many checks from your address. Wait for the number of seconds in the Retry-After header. A host with many IP addresses uses more of the limit than a host with one.
  • 502: the DNS lookup failed, for example because the resolver timed out.
  • 503: this server has no trust store data yet, so it cannot tell whether a chain is trusted. It retries the download automatically. Try again after the number of seconds in Retry-After.

A failed connection to one IP is not an HTTP error. It shows up as error on that IP's result. An address this server may not test, such as a private address, shows up with tested: false and denyReason.

Other endpoints

  • GET /api/config returns ipOverrideAllowed (whether you may send connectIPs) and the trust store's size and age.
  • GET /healthz returns ok while the service is running.

A link can fill in the form for someone else. Opening it does not run the check; they still press Check chain.

https://this-server/?host=www.example.edu&port=443
https://this-server/?host=smtp.example.edu&port=2525&starttls=smtp

starttls takes the same values as in the API and has no control on the page.