DEV3 Technologies home

SSL certificate expiry checker

Open a TLS connection to a host, read the peer certificate and return days remaining, issuer and subject without any extra packages.

LaravelUtilitiesssltlsmonitoringopenssl

Code

<?php

namespace App\Monitoring;

use RuntimeException;

final class SslExpiryChecker
{
    /**
     * @return array{host: string, issuer: string, subject: string, expires_at: \DateTimeImmutable, days_left: int}
     */
    public function check(string $host, int $port = 443, int $timeout = 8): array
    {
        $host = strtolower(trim($host));
        if (! preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/', $host)) {
            throw new RuntimeException('Invalid host name.');
        }

        $context = stream_context_create(['ssl' => [
            'capture_peer_cert' => true,
            'SNI_enabled' => true,
            'peer_name' => $host,
            // We only read the dates, so an already-expired or self-signed cert must still connect.
            'verify_peer' => false,
            'verify_peer_name' => false,
        ]]);

        $socket = @stream_socket_client(
            "ssl://{$host}:{$port}", $errno, $error, $timeout, STREAM_CLIENT_CONNECT, $context
        );

        if ($socket === false) {
            throw new RuntimeException("TLS connection failed: {$error} ({$errno})");
        }

        $params = stream_context_get_params($socket);
        fclose($socket);

        $cert = openssl_x509_parse($params['options']['ssl']['peer_certificate'] ?? '');
        if (! $cert) {
            throw new RuntimeException('Could not read the certificate.');
        }

        $expires = (new \DateTimeImmutable)->setTimestamp($cert['validTo_time_t']);
        $seconds = $cert['validTo_time_t'] - time();

        return [
            'host' => $host,
            'issuer' => $cert['issuer']['O'] ?? $cert['issuer']['CN'] ?? 'unknown',
            'subject' => $cert['subject']['CN'] ?? $host,
            'expires_at' => $expires,
            // floor() so a cert that expired 3 hours ago reports -1, not 0.
            'days_left' => (int) floor($seconds / 86400),
        ];
    }
}

How it works

What it solves

Expired certificates are one of the most avoidable outages. Knowing how many days remain only needs one TLS handshake, so you do not need a third-party API or a package.

How it works

The class opens ssl://host:443 with a stream context that asks PHP to keep the peer certificate. SNI is enabled and peer_name is set, so servers hosting many domains hand back the right certificate. After the handshake, stream_context_get_params() returns the certificate resource and openssl_x509_parse() turns it into an array with validTo_time_t, issuer and subject.

Peer verification is deliberately off: the goal is to read dates, and a monitor should still report on a certificate that is already expired, self-signed or issued for a different name. The result is a small array with an expiry date and a days_left integer computed with floor(), so negative values clearly mean "already expired".

Adapting it

  • Run it daily from the scheduler and alert at 30, 14 and 7 days left, remembering which threshold you already notified.
  • Pass a different port to check mail or API servers that use implicit TLS.
  • Compare subject with the expected name if you also want to catch mismatched certificates.

Pitfalls

  • Validate the host string; never let user input contain slashes or schemes, otherwise the monitor can be pointed at internal services.
  • Private networks: block internal address ranges if tenants can add hosts.
  • STARTTLS ports such as 587 need a different handshake.

How to use it

  1. Make sure the PHP openssl extension is enabled (it is by default).
  2. Put the class in app/Monitoring/SslExpiryChecker.php.
  3. Call it: $info = app(SslExpiryChecker::class)->check('example.com');
  4. Compare $info['days_left'] with your thresholds and notify when it drops below one.
  5. Wrap the call in try/catch: a refused connection throws a RuntimeException.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets