Common Login Portal
Hot-reloading a SAML signing certificate in prod
Ashish Bhagat · 13 Aug 2026
The fix in the previous post — parsing the ministry's SAML signing certificate with BouncyCastle instead of the JDK's default CertificateFactory — solved a real problem. It also introduced a smaller one that took longer to show up: it parsed the certificate exactly once, at startup, and never looked at it again.
That's fine right up until the ministry rotates the key. Government-issued PKI certificates don't last forever, and a national identity provider rotating its signing certificate isn't an edge case, it's routine key hygiene. It happened again, in production, after the parser fix had already shipped. When it did, every SAML assertion the IdP signed with the new certificate failed verification against the old one CLP had cached since deploy — not because anything was broken, but because the two sides had quietly stopped agreeing on which key was current.
A fix that only helps once
There's a broader shape to this that goes beyond SAML specifically: any time a service caches something about an external dependency at startup — a certificate, a signing key, a set of feature flags, a schema version — that cache is a bet that the thing being cached won't change for the lifetime of the process. Sometimes that bet is safe. A national identity provider's signing certificate is exactly the kind of thing that looks stable on any given day and isn't stable on the timescale a long-running production service actually cares about, because certificate lifetimes are measured in months or years while a service's uptime is measured in however long until the next planned or unplanned restart.
The BouncyCastle fix answered "can we parse this certificate at all." It didn't answer "what happens when this certificate stops being the one the IdP actually signs with." Those are different problems that happen to look identical from the outside — SAML sign-in stops working — but the second one doesn't announce itself as a parsing exception. Verification just starts failing, silently and permanently, until someone redeploys with a fresh copy of the metadata.
Most SAML service provider setups, including the way Spring Security's SAML2 support is typically wired, resolve the identity provider's metadata once — at startup, or the first time it's needed — and build a static RelyingPartyRegistration from it. That registration, and the verification certificate inside it, then lives for the lifetime of the process. It's a reasonable default. It also means the SP's idea of "the IdP's current certificate" is frozen the moment the application starts, and drifts further from reality with every rotation the ministry makes afterward.
Refresh on failure, not just on a timer
It's tempting to treat certificate rotation as a rare event a manual runbook can cover — someone gets a notice, someone redeploys, done. That works only as long as the notice arrives before the rotation, on a schedule CLP doesn't control and shouldn't have to depend on. A national identity provider's own key-rotation policy isn't something to negotiate around; the only version of this that doesn't eventually depend on a human being awake at the right moment is one where the system survives the rotation on its own.
A scheduled metadata refresh — re-fetch the IdP's metadata every so often and rebuild the registration — helps, but it's a race against however long the interval is. Rotate the certificate an hour after the last refresh, and every sign-in fails for whatever's left of that window. A short interval shrinks the window but adds constant background load fetching metadata that hasn't changed.
The more direct fix treats a verification failure itself as the signal. If validating an assertion's signature fails against the certificate currently in memory, that's not automatically proof the assertion is fraudulent — it might just mean the certificate in memory is stale. So instead of failing immediately, re-resolve the IdP's metadata on the spot, rebuild the verification credential from whatever certificate is actually current, and retry validation exactly once before giving up. A scheduled background refresh still runs underneath as a second layer, so a rotation gets picked up even during a quiet period with no incoming traffic to trigger the on-failure path — but the failure path is what actually closes the gap to zero, because it reacts to the rotation instead of waiting to notice it.
This is the shape of it — reconstructed for this post, not the literal production code:
// Illustrative — shows the approach, not the production implementation
public boolean validate(Saml2Response response) {
try {
return verifier.verify(response, currentIdpCredential());
} catch (SignatureValidationException ex) {
refreshIdpCredentialFromMetadata(); // re-resolve metadata, rebuild the credential
return verifier.verify(response, currentIdpCredential()); // retry once
}
}
There's a version of this that's tempting and wrong: refresh on a fixed schedule alone, tuned aggressively short, and skip the failure-triggered path entirely. It's simpler to write. It's also strictly worse, because it optimizes for the wrong thing — a short polling interval reduces the average staleness window but never eliminates it, and the one moment staleness actually matters is exactly the moment right after a rotation, which a fixed schedule has no way to detect early. The failure-triggered path doesn't average anything. It reacts to the specific event that makes the cached certificate wrong, the instant that event has a visible effect, instead of waiting for the next tick of a clock that doesn't know a rotation just happened.
The retry-once part matters. Without it, a genuinely forged assertion would trigger an unbounded refresh-and-retry loop every time it arrived, which is its own denial-of-service surface. One retry is enough to absorb a legitimate rotation and no more.
It's also worth logging deliberately, not just handling silently. A refresh triggered by a verification failure is, by definition, a moment where something about the trust relationship with the IdP just changed underneath the application. Whether that turns out to be a routine rotation or something worth real suspicion, it's the kind of event that should leave a visible trace — not necessarily an alert that pages someone, but at minimum a log line that says a refresh happened and why, so the next person looking at authentication metrics can tell the difference between "a rotation happened and healed itself, as designed" and "verification is failing for some other reason entirely, and the refresh path is quietly masking it."
What I'd do differently
I built this after the fact — the BouncyCastle parser shipped first, on-failure refresh came later, once a second real rotation in production proved the first fix alone wasn't durable. In hindsight the two belong together. A certificate you had to hand-parse because the standard tooling rejected it is exactly the certificate you should assume will need reloading without a deploy, because you've already established that this IdP's PKI operations don't run on your schedule. If I were doing this again, the on-failure refresh would ship in the same change as the manual parser, not as a lesson learned from the next rotation.
More from this series:
AVA not a sequence: a Java 17 SAML cert bug · Hexagonal architecture you can verify with grep