A scheduled action that checks your websites' SSL certificates and posts a warning to your channel before any of them expire
Catch an expiring or invalid SSL certificate before your users or customers do. A PowerShell workflow action checks each site's certificate and reports days to expiry, a weekly schedule runs it for you, and it posts to your team's channel when a certificate needs attention. For MSPs and IT teams that look after public websites, portals and remote-access gateways.
| Runs in | Cloud sandbox (PowerShell) |
| Uses | The IT Alerts Channel webhook credential, to post warnings (optional, but a schedule without it alerts nobody) |
| Suggested approval | None (engineers and admins run it without approval), because it only reads public information |
| Category | Reporting |
Test it with SuperIT before you rely on it
This is an example, not a ready-made action, and it hasn't been tested end to end. Before you use it, work through it with SuperIT: ask SuperIT to review the script against your environment and requirements, and adjust it together. Then test it, as in Test it. Test in the action editor isn't a dry run: it runs the script for real.
Why use an action instead of asking SuperIT
SuperIT can't open a website or read its certificate in a chat: it has no web request tool, and web requests from devices (Invoke-WebRequest and Invoke-RestMethod) are blocked by a locked command policy. This action checks a list of HTTPS sites from the cloud sandbox and reports days to expiry. Its real value is a weekly schedule that posts to your channel when any certificate is invalid or inside your warning window.
To find out who issues a certificate or how to renew it, just ask SuperIT in chat.
When to use it
- "Warn us every Monday if any client certificate expires within 30 days."
- "When does the certificate on remote.contoso.com expire?"
- "Check all our client websites' certificates after the renewal."
What the action does
For each host in the list:
- Connects on HTTPS (port 443 unless you give another port from the script's allowed list, 443 and 8443 by default) and validates the certificate the way a browser would.
- Reads the certificate's expiry date and issuer, and works out the days left.
- Marks the host OK, WARN (expires within
warn_days) or FAIL (expired, invalid or unreachable).
It prints problems first, then the rest, and returns all results. If any host is WARN or FAIL and the IT Alerts Channel credential is linked, it posts those hosts to that channel.
What it doesn't do:
- It doesn't renew certificates. Renewal stays with your certificate provider or hosting platform.
- It doesn't check internal sites. The cloud sandbox reaches public internet addresses only; for an intranet site, use a device action (see Query an on-premises system from a device).
- It doesn't check whether the site is up or what its pages return. Schedules run at most once a day, which is too slow for uptime monitoring; use a monitoring service for that.
Before you begin
- A list of hostnames to check, such as
www.contoso.com, remote.contoso.com, mail.contoso.com:8443. An IPv6 address goes in square brackets, such as[2001:db8::10]:443. - The ports you check. The script accepts only the ports in
$allowedPorts, 443 and 8443 by default, so the action can't be used to probe other ports on other people's hosts. Add any other port you need, such as 465. - A webhook credential for alerts. Create the
IT Alerts Channelcredential from Post a message to a Teams or Slack channel and link it to this action. The script reads it asCREDENTIAL_IT_ALERTS_CHANNEL_WEBHOOK_URL. Without it, the action still works, but a scheduled run's output only appears under the action's Executions tab. - No authorization policy is needed: with none, engineers and admins can run it without approval. See Approvals.
- Your role. Creating an action in chat needs the Admin role. Engineers can build it by hand under Workflows → Actions, and engineers can create schedules in chat.
Build it with SuperIT
Create an action called "Check website and SSL expiry".
Purpose: for each host, validate the HTTPS certificate and report days until expiry and issuer.
Runtime: PowerShell in the cloud sandbox. Read the certificate with System.Net.Sockets.TcpClient and System.Net.Security.SslStream.
Inputs: hosts (string[], required, comma-separated, may include :port; IPv6 in [brackets]), warn_days (string, optional, default 30). Allow only ports 443 and 8443.
Credential: "IT Alerts Channel" (webhook URL in CREDENTIAL_IT_ALERTS_CHANNEL_WEBHOOK_URL), optional. If linked and any host is WARN or FAIL, post those hosts to it (Slack text or Teams Adaptive Card). Never print the webhook URL.
Status per host: OK, WARN (expires within warn_days) or FAIL (expired, invalid certificate or unreachable). Print problems first.
No authorization policy: engineers and admins only, no approval.
SuperIT drafts the script, validates it and creates the action as a draft. You review it, test it and publish it under Workflows → Actions.
Or build it yourself
| Name | Type | Required | Example |
|---|---|---|---|
hosts |
string[] | Yes | www.contoso.com, remote.contoso.com |
warn_days |
string | No | 30 |
A string[] input reaches the script as one comma-separated string, so the script splits it. Set Runtime to PowerShell:
# Goal: for each host, validate the HTTPS certificate and report days to expiry and issuer, then post
# any WARN or FAIL hosts to the alerts channel when its webhook credential is linked. Changes nothing.
$webhookUrl = "$env:CREDENTIAL_IT_ALERTS_CHANNEL_WEBHOOK_URL"
$allowedPorts = @(443, 8443) # add ports you need, such as 465
function Invoke-SslExpiryCheck {
param([string]$Hosts, [string]$WarnDays)
$hostList = @(ConvertTo-HostList -Hosts $Hosts)
$warn = ConvertTo-WarnDays -WarnDays $WarnDays
$order = @{ FAIL = 0; WARN = 1; OK = 2 }
$results = @($hostList | ForEach-Object { Test-Host -Entry $_ -WarnDays $warn } |
Sort-Object -Property { $order[$_.status] })
$flagged = @($results | Where-Object { $_.status -ne "OK" })
Write-HostReport -Results $results -Problems $flagged.Count -WarnDays $warn
$posted = $false
if ($flagged.Count -gt 0) {
$lines = @($flagged | ForEach-Object { "$($_.status) $($_.host): $($_.note)" })
$posted = Send-ChannelAlert -Title "Certificate check: $($flagged.Count) site(s) need attention" -Lines $lines
if ($posted) { Write-Output "Posted the warnings to the alerts channel." }
}
# $script: so the runner can still read $result after this function returns.
$script:result = @{ warn_days = $warn; problems = $flagged.Count; hosts = $results; alert_posted = $posted }
}
function ConvertTo-HostList {
param([string]$Hosts)
$list = @($Hosts -split ',' | ForEach-Object { ($_.Trim() -replace '^https://', '').Split('/')[0] } |
Where-Object { $_ })
if ($list.Count -eq 0) { throw "Input 'hosts' needs at least one hostname." }
if ($list.Count -gt 50) { throw "Check 50 hosts or fewer per run." }
return $list
}
function ConvertTo-WarnDays {
param([string]$WarnDays)
if ([string]::IsNullOrWhiteSpace($WarnDays)) { return 30 }
$value = 0
if (-not [int]::TryParse($WarnDays.Trim(), [ref]$value)) { throw "Input 'warn_days' must be a whole number." }
return $value
}
function Test-Host {
param([string]$Entry, [int]$WarnDays)
$row = [ordered]@{ host = $Entry; status = "OK"; days_left = $null; issuer = $null; note = "" }
try {
$hostName, $port = Split-HostPort -Entry $Entry
} catch {
$row.status = "FAIL"
$row.note = $_.Exception.Message
return $row
}
try {
$tls = Get-TlsCertificate -HostName $hostName -Port $port
} catch {
$row.status = "FAIL"
$row.note = "no TLS connection: $($_.Exception.GetBaseException().Message)"
return $row
}
$cert = $tls.cert
if ($cert) {
$expires = $cert.NotAfter.ToUniversalTime()
$row.days_left = [int][math]::Floor(($expires - [datetime]::UtcNow).TotalDays)
$row.issuer = if ($cert.Issuer -match '(?:^|,\s*)O=("[^"]*"|[^,]+)') { $Matches[1].Trim('"') } else { $cert.Issuer }
}
if ($tls.errors -ne [System.Net.Security.SslPolicyErrors]::None) {
$row.status = "FAIL"
$row.note = "certificate invalid: $($tls.errors) $($tls.chain -join ', ')".Trim()
return $row
}
if ($row.days_left -le $WarnDays) {
$row.status = "WARN"
$row.note = "expires $($expires.ToString('yyyy-MM-dd'))"
}
return $row
}
function Split-HostPort {
param([string]$Entry)
# [2001:db8::10]:443 for IPv6, host or host:port otherwise.
if ($Entry -match '^\[([0-9A-Fa-f:.]+)\](?::(\d{1,5}))?$') { $hostName = $Matches[1]; $portText = $Matches[2] }
elseif ($Entry -match '^([A-Za-z0-9.-]+)(?::(\d{1,5}))?$') { $hostName = $Matches[1]; $portText = $Matches[2] }
else { throw "not a hostname, host:port or [IPv6]:port" }
$port = if ($portText) { [int]$portText } else { 443 }
if ($allowedPorts -notcontains $port) { throw "port $port isn't in the allowed list ($($allowedPorts -join ', '))" }
return $hostName, $port
}
function Get-TlsCertificate {
param([string]$HostName, [int]$Port)
$captured = @{ cert = $null; errors = $null; chain = @() }
# Accept every certificate during the handshake so an expired or untrusted one can still be read;
# the policy errors captured here decide the result instead.
$callback = [System.Net.Security.RemoteCertificateValidationCallback]{
param($sender, $certificate, $chain, $sslPolicyErrors)
if ($certificate) { $captured.cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certificate) }
$captured.errors = $sslPolicyErrors
if ($chain) { $captured.chain = @($chain.ChainStatus | ForEach-Object { "$($_.Status)" }) }
return $true
}.GetNewClosure()
$client = [System.Net.Sockets.TcpClient]::new()
$tls = $null
try {
if (-not $client.ConnectAsync($HostName, $Port).Wait(10000)) { throw "timed out connecting to port $Port" }
$client.ReceiveTimeout = 10000
$client.SendTimeout = 10000
$tls = [System.Net.Security.SslStream]::new($client.GetStream(), $false, $callback)
$tls.AuthenticateAsClient($HostName)
} finally {
if ($tls) { $tls.Dispose() }
$client.Dispose()
}
return $captured
}
function Write-HostReport {
param([object[]]$Results, [int]$Problems, [int]$WarnDays)
Write-Output "=== WEBSITE AND SSL CHECK ==="
Write-Output "Hosts checked : $($Results.Count)"
Write-Output "Need attention : $Problems"
Write-Output "Warning window : $WarnDays days"
foreach ($r in $Results) {
$note = if ($r.note) { "| $($r.note)" } else { "" }
Write-Output ("{0,-4} {1} | {2} days left | {3} {4}" -f $r.status, $r.host, $r.days_left, $r.issuer, $note)
}
}
function Send-ChannelAlert {
param([string]$Title, [string[]]$Lines)
if (-not $webhookUrl) { return $false }
$text = "$Title`n" + ($Lines -join "`n")
if ($webhookUrl -like "*hooks.slack.com*") {
# Escaped so Slack shows <, > and & as typed instead of reading them as mentions or links.
$payload = @{ text = $text.Replace('&', '&').Replace('<', '<').Replace('>', '>') }
} else {
$card = @{ type = "AdaptiveCard"; version = "1.4"; body = @(@{ type = "TextBlock"; text = ($text -replace "`n", "`n`n"); wrap = $true }) }
$payload = @{ type = "message"; attachments = @(@{ contentType = "application/vnd.microsoft.card.adaptive"; content = $card }) }
}
$json = $payload | ConvertTo-Json -Depth 10
try {
Invoke-RestMethod -Method Post -Uri $webhookUrl -ContentType "application/json; charset=utf-8" `
-Body ([System.Text.Encoding]::UTF8.GetBytes($json)) -TimeoutSec 30 | Out-Null
} catch {
# Never include $webhookUrl in a message: anyone holding it can post to the channel.
throw "Failed to post the certificate alert to the channel: $($_.Exception.Message)"
}
# Only the Boolean is output: the caller captures it as alert_posted, so a message here would turn it into an array.
return $true
}
Invoke-SslExpiryCheck -Hosts $ctx.inputs.hosts -WarnDays $ctx.inputs.warn_days
Test it
- Save the draft and select Test.
- Test is not a dry run: it skips authorization and approvals and uses real network access. This action only reads public information, so testing is safe. Test runs aren't billed.
- Link the
IT Alerts Channelcredential, enterhostsas one of your sites plusexpired.badssl.com, a public test site with an expired certificate, and confirm that one shows FAIL and is posted to the channel. - Select Publish.
Use it
- Weekly: ask SuperIT "Schedule Check website and SSL expiry every 7 days, starting Monday at 08:00, for www.contoso.com, remote.contoso.com and portal.contoso.com, with warn_days 30". Schedules are created in chat only, repeat every N elapsed days and show a View schedule link. A schedule doesn't send results anywhere itself: with the
IT Alerts Channelcredential linked, the script posts only when a certificate needs attention, and every run's output is under the action's Executions tab. - In chat: "When does the certificate on remote.contoso.com expire?"
- Managed teams: publish it with Publish to teams so each customer's own engineers can check their own sites. If you linked the
IT Alerts Channelcredential, each copy gets a blank credential, and its runs are refused until that team's admin fills it in. Without a linked credential, the copies work straight away but alert nobody.
Variations
- One schedule per customer: keep one action and create a schedule per customer with that customer's hosts.
- Mail server certificates: check SMTP on port 465, or remote-access gateways on their own ports, with
host:port. Add each port to$allowedPortsfirst. - Domain expiry too: see Check domain and DNS records.
- Fail the run instead of posting:
throwat the end ofInvoke-SslExpiryCheckwhen$flagged.Countis more than 0, so problem runs show as failed under Executions.