Managing SSL certificates for a single domain is straightforward. Managing them across 20, 50, or 500 domains requires automation. An expired certificate on a forgotten subdomain can silently break API calls, payment flows, or email delivery. This guide covers three approaches to bulk SSL checking: a bash one-liner for quick checks, a Python script with CSV output for ongoing monitoring, and what to look for in a bulk-check tool.
Method 2: Python Script with Days-Until-Expiry and CSV Output
For regular monitoring, a Python script calculates how many days remain and generates a CSV report you can import into a spreadsheet or monitoring tool:
#!/usr/bin/env python3
# bulk_ssl_check.py — check SSL expiry for a list of domains
import ssl, socket, csv, sys
from datetime import datetime, timezone
def check_ssl(domain, port=443, timeout=10):
ctx = ssl.create_default_context()
try:
with socket.create_connection((domain, port), timeout=timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
not_after = datetime.strptime(
cert['notAfter'], '%b %d %H:%M:%S %Y %Z'
).replace(tzinfo=timezone.utc)
days_left = (not_after - datetime.now(timezone.utc)).days
san = [v for t, v in cert.get('subjectAltName', []) if t == 'DNS']
issuer = dict(x[0] for x in cert['issuer']).get('organizationName', 'Unknown')
return {'domain': domain, 'expiry': not_after.strftime('%Y-%m-%d'),
'days_left': days_left, 'issuer': issuer,
'san_count': len(san), 'status': 'OK' if days_left > 0 else 'EXPIRED',
'warning': 'EXPIRING SOON' if 0 < days_left <= 30 else ''}
except Exception as e:
return {'domain': domain, 'expiry': '', 'days_left': -1,
'issuer': '', 'san_count': 0,
'status': 'ERROR', 'warning': str(e)}
domains = [line.strip() for line in open('domains.txt') if line.strip()]
results = [check_ssl(d) for d in domains]
# Print summary to console:
for r in results:
status = f"[{r['status']}]" if r['status'] == 'OK' else f"[*** {r['status']} ***]"
print(f"{r['domain']:40} {status:20} {r['days_left']:4} days {r['warning']}")
# Write CSV report:
with open('ssl_report.csv', 'w', newline='') as f:
w = csv.DictWriter(f, fieldnames=results[0].keys())
w.writeheader()
w.writerows(results)
print(f"\nReport saved to ssl_report.csv")
# Run the script:
python3 bulk_ssl_check.py
# Filter just expiring/expired domains:
python3 bulk_ssl_check.py | grep -E "EXPIRED|EXPIRING"
Method 3: Parallel Checking for Speed
The serial Python approach above can take 10+ seconds per domain if any have slow TLS handshakes. Use concurrent.futures to check dozens of domains simultaneously:
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=20) as executor:
futures = {executor.submit(check_ssl, d): d for d in domains}
results = []
for future in as_completed(futures):
results.append(future.result())
# Sort by days_left ascending (most urgent first):
results.sort(key=lambda x: x['days_left'])
With 20 workers, checking 100 domains takes about 5–10 seconds total instead of 15–20 minutes. Increase max_workers cautiously — very high values can trigger rate limits at some servers.
Automate Weekly Monitoring via Cron
# Run the check every Monday at 8 AM and email results:
# Add to crontab (crontab -e):
0 8 * * 1 cd /opt/ssl-monitor && python3 bulk_ssl_check.py \
&& mail -s "Weekly SSL Report" you@yourdomain.com < ssl_report.csv
# Or use a more targeted alert — email only when certs expire in <30 days:
0 8 * * 1 cd /opt/ssl-monitor && python3 bulk_ssl_check.py 2>&1 \
| grep -E "EXPIRING|EXPIRED" | mail -s "SSL ALERT: Action Required" you@yourdomain.com
What a Bulk SSL Check Should Verify
Expiry date is the most important thing to monitor, but a complete check also confirms:
- Certificate is trusted — issued by a publicly trusted CA (not self-signed)
- Chain is complete — intermediate certificates are served (missing intermediate is the #1 installation error)
- Domain matches — certificate SANs include the exact hostname being checked
- TLS protocol — TLS 1.2 or 1.3 (TLS 1.0/1.1 are prohibited by PCI DSS 4.0)
- No weak ciphers — RC4, DES, 3DES, MD5 are deprecated
One domain can have multiple certificates (apex + subdomain, or primary + backup). Checking only yourdomain.com will not catch an expired cert on api.yourdomain.com. Build your domain list to include all FQDNs, not just the primary domain.
Using Our Online Bulk SSL Checker
Our tool checks all the above attributes in one request — no scripts required. Enter a domain, and we return the certificate's expiry, chain validity, issuer, TLS version, and cipher suite immediately. For batch checking, paste a list of domains and get a combined results table with CSV export.
Bulk SSL Checking on Windows with PowerShell
Windows
PowerShell
The Python script shown above works on Windows too (install Python from python.org). But for Windows administrators, here is a pure PowerShell equivalent that checks SSL certificates across multiple domains and exports a CSV report:
# bulk-ssl-check.ps1 — Check SSL expiry across multiple domains
# Usage: .\bulk-ssl-check.ps1 -DomainsFile domains.txt -OutputCsv ssl-report.csv
param(
[string]$DomainsFile = "domains.txt",
[string]$OutputCsv = "ssl-report.csv",
[int]$Port = 443,
[int]$TimeoutSeconds = 10,
[int]$WarnDays = 30
)
function Check-SslCert {
param([string]$Domain, [int]$Port, [int]$Timeout)
try {
$tcp = New-Object System.Net.Sockets.TcpClient
$tcp.ConnectAsync($Domain, $Port).Wait([timespan]::FromSeconds($Timeout)) | Out-Null
$ssl = New-Object System.Net.Security.SslStream($tcp.GetStream(), $false,
({param($s,$c,$ch,$e) $true}))
$ssl.AuthenticateAsClient($Domain)
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($ssl.RemoteCertificate)
$daysLeft = ($cert.NotAfter - (Get-Date)).Days
$ssl.Close(); $tcp.Close()
return [PSCustomObject]@{
Domain = $Domain
Expiry = $cert.NotAfter.ToString("yyyy-MM-dd")
DaysLeft = $daysLeft
Issuer = ($cert.Issuer -replace "^CN=","" -split ",")[0]
Subject = $cert.Subject
Status = if ($daysLeft -lt 0) {"EXPIRED"} elseif ($daysLeft -le $WarnDays) {"EXPIRING"} else {"OK"}
}
} catch {
return [PSCustomObject]@{
Domain = $Domain
Expiry = ""
DaysLeft = -999
Issuer = ""
Subject = ""
Status = "ERROR: $_"
}
}
}
# Read domain list
$domains = Get-Content $DomainsFile | Where-Object {$_.Trim() -ne ""}
# Run checks in parallel using runspaces
$results = $domains | ForEach-Object -ThrottleLimit 20 -Parallel {
$domain = $_.Trim()
& $using:function:Check-SslCert $domain $using:Port $using:TimeoutSeconds
}
# Sort by days left (most urgent first)
$sorted = $results | Sort-Object DaysLeft
# Display to console
$sorted | Format-Table Domain, Status, DaysLeft, Expiry, Issuer -AutoSize
# Export to CSV
$sorted | Export-Csv -Path $OutputCsv -NoTypeInformation
Write-Host "`nReport saved to $OutputCsv"
# Summary
$expired = ($sorted | Where-Object {$_.Status -eq "EXPIRED"}).Count
$expiring = ($sorted | Where-Object {$_.Status -eq "EXPIRING"}).Count
Write-Host "`nSummary: $expired expired, $expiring expiring within $WarnDays days"
Run it from PowerShell:
# Run with defaults (reads domains.txt, writes ssl-report.csv)
.\bulk-ssl-check.ps1
# Custom file and warn threshold
.\bulk-ssl-check.ps1 -DomainsFile mydomains.txt -OutputCsv results.csv -WarnDays 60
# Schedule via Windows Task Scheduler (weekly, Mondays at 8 AM):
$action = New-ScheduledTaskAction -Execute "PowerShell.exe" `
-Argument "-NonInteractive -File C:\ssl-monitor\bulk-ssl-check.ps1"
$trigger = New-ScheduledTaskTrigger -Weekly -WeeksInterval 1 -DaysOfWeek Monday -At "08:00"
Register-ScheduledTask -TaskName "SSL Monitor" -Action $action -Trigger $trigger -RunLevel Highest
The
-Parallel parameter with
-ThrottleLimit 20 checks 20 domains simultaneously — 100 domains takes about 5–10 seconds on a normal internet connection. This requires PowerShell 7+. Install from
github.com/PowerShell/PowerShell.
macOS: Bash Script
On macOS, the bash one-liner and Python script from the Linux section work without modification. Use the macOS-native date command variant for date calculations:
# macOS bulk check bash script (note: macOS date uses different syntax than Linux)
while IFS= read -r domain; do
expiry=$(echo | openssl s_client -connect "$domain:443" \
-servername "$domain" 2>/dev/null | \
openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -n "$expiry" ]; then
# macOS date -j -f syntax (different from Linux date)
expiry_epoch=$(date -j -f "%b %d %T %Y %Z" "$expiry" +%s 2>/dev/null)
now_epoch=$(date +%s)
days=$(( (expiry_epoch - now_epoch) / 86400 ))
printf "%-40s %4d days %s\n" "$domain" "$days" "$expiry"
else
printf "%-40s ERROR\n" "$domain"
fi
done < domains.txt