it's 2:14am. deploys are failing in eu-central-1 and nowhere else. i pull the cert off the load balancer, check it on my laptop, and it's fine. issued four hours ago, good for another 200 days. i check the same cert from a failing host and the handshake dies before it finishes.
same cert. same chain. same trust store.
the certificate is fine. the clock isn't.
this isn't an exotic failure. it's just badly served by tooling, and the error messages point you the wrong way on purpose. sort of. here's what's actually happening, and how to triage it before you do something you can't undo, like reissuing a perfectly healthy certificate.
the error strings lie by omission
go's crypto/x509 gives you this: x509: certificate has expired or is not yet valid. one string, two opposite conditions. newer go versions tack on a detail clause — current time 2026-07-20T02:14:33Z is before 2026-07-20T03:11:00Z — which actually tells you something. except that suffix gets truncated by log pipelines, swallowed by error wrapping, or nuked entirely when some library re-wraps the whole thing as a generic connection error. what shows up in your alerting channel is the front half. always the front half.
openssl does better. verify error 9 is "certificate is not yet valid", verify error 10 is "certificate has expired". different codes, different strings. java does better still: CertificateNotYetValidException and CertificateExpiredException are separate types you can catch separately. python's ssl module surfaces the openssl wording underneath, so you get "certificate is not yet valid" spelled out.
but go is what an enormous share of infrastructure tooling is written in. that collapsed message is why every on-call engineer reads "expired or is not yet valid" as "expired." expiry is the failure we've all been trained on for a decade. so you check notAfter, see a date in january, get confused, and start building a theory about intermediate chains or a bad trust bundle. meanwhile the box is running 90 minutes fast and the cert hasn't started existing yet.
first triage question isn't "when does this expire." it's "which end of the window are we on."
two comparisons against a number nobody watches
rfc 5280 §6.1.3 is not subtle about this. path validation checks that the current time falls inside the validity period of every cert in the chain. two fields, two comparisons, zero tolerance. no grace window anywhere in the spec. one second before notBefore and you're not valid, full stop.
that's a lot of weight resting on a value most teams never graph. you alert on disk, cpu, memory, error rate, p99. does anything in your stack page you when a host's clock offset crosses a threshold? in most places i've worked, no. time gets assumed correct the same way dns gets assumed correct, right up until the night it isn't.
CAs know this, which is why some of them build in a small mercy. let's encrypt backdates notBefore by one hour from actual issuance. that hour isn't decoration. it exists because a meaningful chunk of the internet's clocks are wrong, and handing out certs that are valid the instant they're signed would produce a permanent background drizzle of failures on fast hosts.
here's the part that should bother you: that hour is your entire margin. a host running 65 minutes fast rejects a brand new let's encrypt cert. not every CA backdates. the ones that do don't agree on how much. pull from a private CA that nobody configured backdating on and your margin is zero — any positive skew on the deploying host fails immediately, every time.
where clocks actually break
the theory is that ntp handles it. in practice here's a list of places ntp quietly isn't handling it.
vms coming back from snapshot or live migration. a guest suspended for six hours wakes up convinced it's still six hours ago. kvm-clock and a guest agent usually fix this, but not instantly, and anything that starts before the correction lands reads stale time. if your ci runners are snapshot-restored vms this stops being a curiosity and becomes a weekly thing.
containers. a container cannot fix a bad host clock. CLOCK_REALTIME isn't namespaced in any way that helps you. linux time namespaces (5.6+) virtualize monotonic and boottime, not wall time, and setting the time inside a container needs CAP_SYS_TIME and hits the host anyway. so the container believes whatever the node believes. if the node came from an image where chrony was never enabled, every pod on it is wrong together, in lockstep. that's the version that looks like a regional outage on your dashboard. it isn't a region. it's a node pool.
time sync that's installed but not working. chrony present, not enabled. timesyncd masked by somebody's hardening baseline. egress rules that permit tcp 443 and drop udp 123 into a black hole. ntp fails open in the worst possible way: the daemon stays up, logs at a level nobody reads, and the clock just walks off. free-running oscillators drift seconds to tens of seconds a day depending on temperature and how cheap the crystal was. three weeks unsynced and you're minutes out.
edge and embedded. dead rtc battery means the device boots to the epoch or to whatever date the firmware was built. pi 4 and earlier have no rtc at all — they lean on fake-hwclock, which dumps the last known time to disk on shutdown and restores it on boot, so any unclean power loss leaves the thing believing it's whenever it last checkpointed. the pi 5 finally added a real rtc, but only if somebody actually plugged the battery in. mine sat in a drawer for two months before i bothered. a fleet of edge devices that all lost power in one outage all come back with the same wrong time and all fail tls identically, which is genuinely disorienting to debug because it looks so much like a cert problem.
and the chicken-and-egg one. nts (rfc 8915) secures time sync with tls 1.3 over tcp 4460. https-based time sources have the same shape. both need cert validation to work, and cert validation needs a believable clock. a device booting to 1970 cannot validate the certificate of the server that would tell it what year it is. the escape hatches — an unauthenticated first sync, or shipping a build timestamp as a floor — are compromises. nobody's solved this cleanly.
why 47-day certs make it worse
under sc-081v3 the maximum lifetime steps down: 398 days to 200 (in effect since march 2026), 100 in march 2027, 47 in march 2029 with domain validation reuse capped at 10 days.
everyone's talking about expiry. notBefore deserves more of that airtime.
think about it as exposure. at 398 days, a cert crosses its notBefore boundary once a year. host is 20 minutes fast? that's one narrow window, once, per cert, per year, where a deploy could faceplant. you'd never notice. if you did notice you'd call it a flake and move on with your life.
at 47 days that same cert gets minted about eight times a year. multiply by a few hundred certs and the rounding error becomes a recurring incident class. nothing about the drift changed. you just rolled the dice eighty times more often.
this math already bites anyone running short-lived internal pki. step-ca defaults to 24-hour certs. spiffe/spire hands out x.509 svids with ttls in hours, sometimes minutes. at a one-hour lifetime, five minutes of skew eats 8% of the validity window before anything else goes wrong. and if the skew is positive and your issuer doesn't backdate, the workload might never see that cert as valid at any point in its life. short-lived certs turn clock discipline from hygiene into a hard dependency.
triage
when a not-yet-valid or expired error lands, go in this order.
1. check the clock on the failing host before you touch the certificate. not your laptop. not the bastion. the specific box that's failing. read system time in utc, compare against something you trust. off by more than a few seconds and you're done — stop investigating the pki.
2. read both validity fields. pull the cert with openssl x509 and look at the whole validity block, not just the line your eye goes to. compare notBefore against the failing host's utc time. do this on the failing host, so you're comparing against the clock that's actually making the decision.
3. interrogate the time daemon. chronyc tracking gives you system time offset, stratum, root dispersion, leap status. leap status "Not synchronised" or stratum 16 means chrony is up and has no usable source, which is worse than chrony being down because it looks healthy. the process existing tells you nothing. on timesyncd, timedatectl will say whether the clock is synchronized at all. while you're in there, confirm udp 123 egress is actually permitted.
4. split the vantage points. fastest way to separate the two failure modes. if a cert fails from one host and validates from several independent locations, the cert is fine and you have a host problem. fails everywhere, it's the cert. this is a big part of why certpulse probes endpoints externally rather than trusting what your infrastructure reports about itself — a host with a broken clock is an unreliable narrator about its own certificates.
5. make clock offset a first-class alert. next to your expiry thresholds, alert on sustained offset above ~500ms and page on unsynchronized. cheap metric, ugly failure mode. every node exporter already collects it. the gap isn't collection, it's that nobody wires the alert until after the incident that would have justified it.
and then the runbook rule, which is the whole post in one line: never reissue a certificate to fix a not-yet-valid error until you've ruled out time. reissuing gives you a cert with an even later notBefore. on a fast host you've made it strictly worse, burned rate limit against your CA, and created a new cert nobody has inventoried. i've watched a team burn through three reissues at 3am before somebody thought to run date on the box.
two timestamps, one clock. check the clock first.
-- alex
the machine will tell you the certificate is bad. the machine is lying about what it knows.
This is why we built CertPulse
CertPulse connects to your AWS, Azure, and GCP accounts, enumerates every certificate, monitors your external endpoints, and watches Certificate Transparency logs. One dashboard for every cert. Alerts when auto-renewal fails. Alerts when certs approach expiry. Alerts when someone issues a cert for your domain that you didn't request.
If you're looking for complete certificate visibility without maintaining scripts, we can get you there in about 5 minutes.