The SAML guard that compared a clock to a string

Single sign on runs on trust documents called assertions. When you log in through an identity provider, it hands the application a signed SAML assertion that says, in effect, "this is Alice, and this statement is valid from 10:00 to 10:05." The application checks the signature and the time window, and if both hold, Alice is in.

That time window does real work. The "not valid before" and "not valid on or after" timestamps are what stop an attacker from replaying an old captured assertion, or presenting one that was minted for a different moment. They're a core security control of the protocol.

On a recent review of a widely used SAML authentication library, the lower half of that window was not being enforced at all. Every assertion was treated as already valid no matter what its "not before" time said. The cause was a single line, and the reason it stayed invisible is a quirk of the language it was written in.

Severity: CVSS 5.9 (Medium) · CWE-697, CWE-1254
Impact: the lower validity bound was never enforced — every assertion was treated as already valid — widening the replay window. Not a standalone account takeover.
Reach: the flaw sat in a shared library, so it propagated to every product that trusted it.

One line, one missing word

The validation routine did the right setup. It read the "not before" timestamp out of the assertion, parsed it into a proper date object, and even adjusted that object for allowable clock skew. Then, in the comparison, it used the wrong variable.

It compared the current time against the original timestamp string, not against the date object it had just carefully built. In shorthand, it asked now < notBefore where notBefore was still the raw text from the document, while the parsed and skew adjusted value sat unused right next to it.

The fix is to compare against the object. The bug is that it compared against the string. One word.

// the parsed, skew-adjusted value sits right next to the raw string…
const notBefore = parseDate(cond.NotBefore);   // a real Date
if (now < cond.NotBefore) return reject();      // …but this compares the STRING

Why the language hid it

In a stricter language this would have thrown a type error and someone would have noticed in the first test run. In JavaScript it failed silently, which is worse.

When you compare a date to a string with the less than operator, the language tries to turn both sides into numbers. The date becomes a millisecond timestamp, a real number. The string, something like a full ISO timestamp with letters and punctuation, cannot become a number, so it becomes the special value that means "not a number." Any comparison against that value is false. Always.

So the check now < notBefore did not sometimes pass and sometimes fail. It evaluated to false every single time, for every assertion, forever. The branch that was supposed to reject an assertion presented too early simply never ran. A comparison that can never be true is a guard you no longer have, even though the line is still sitting there in the source.

The neighboring check, the "not on or after" one, was written correctly against its parsed object, so the upper bound of the window worked and the lower bound did not. That split is what gives it away: the same function got it right once and wrong once, which is the fingerprint of a copy-adjust-and-miss mistake rather than a deliberate design choice.

Why it matters

On its own, disabling the lower time bound is not a full account takeover, and an honest writeup says so. What it removes is one of the protocol's defenses against assertions being accepted outside their intended moment. The validity window exists to narrow the time in which a captured or pre generated assertion is useful. Turn half of it off and you widen that window, which strengthens any replay or timing-based attack against the sign-on flow. In a defense-in-depth control you don't get to assume the other layers are perfect, and this layer was no longer doing anything.

The bigger reason it matters is reach. This was not one application's mistake in its own code. It was in a shared authentication library, the kind that many products pull in precisely so they do not have to get SAML right themselves. One silent line propagates to everyone who trusted the library to do the checking.

How you find this class of bug

You do not find it by sending valid logins, because valid logins work. You find it by sending invalid ones and checking that they are rejected.

Mint an assertion whose "not before" time is firmly in the future and present it. A correct implementation refuses it. If the login succeeds, the lower bound is not being enforced. The same method finds the mirror image bug on the upper bound: present an expired assertion and confirm it is refused. Security tests have to assert the negative. Most test suites only prove that the good case is accepted, which is exactly why a guard that never fires can sit in a popular library for years.

> new Date() < "2099-01-01T00:00:00Z"
false      // string coerces to NaN; every comparison with NaN is false

# so an assertion whose notBefore is in the future is still accepted:
$ forge-assertion --not-before 2099-01-01 --sign key.pem | curl -s .../acs --data @-
HTTP/302  Location: /dashboard       # should have been rejected

The fix

  1. Compare against the parsed, skew adjusted date object on both bounds, never against the raw string. If the language allows it, give the parsed values names distinct enough that you cannot grab the wrong one by reflex.
  2. Add tests that present a not yet valid assertion and an expired assertion and require both to be rejected. Treat the rejection path as a feature with its own coverage.
  3. In any security comparison written in a loosely typed language, be suspicious of mixed types. A date against a string, a number against a string from a request body, a boolean against the literal text true. The language will not warn you. It will just quietly decide your check is false.

Prevent it in CI/CD: negative-path tests that require both a not-yet-valid and an expired assertion to be rejected; a lint rule flagging cross-type comparisons in security code; and pinning plus re-testing third-party SAML/OAuth/JWT libraries against the negative cases on every bump, regardless of popularity.

Checks worth running on your own sign-on

Single sign-on rests on a handful of small comparisons being exactly right. This one was off by a single variable, in a language that won't complain about comparing a date to a string, and it shipped to everyone who pulled the library. Test the negative cases, name your variables so you can't grab the wrong one by reflex, and watch mixed-type comparisons in any code that decides who gets in.