CVE-2026-116201: Replaying One Snipe-IT SSO Login Into Two Sessions
The short version
Snipe-IT is an open source IT asset tracker that supports SAML single sign-on, the enterprise login flow where an identity provider vouches for you and hands the application a signed “assertion” proving who you are. Those assertions are supposed to be single-use: present it once, get one session. I found a race condition where an attacker who captured a single assertion could present it twice at the same instant and walk away with two separate logged-in sessions. The practical sting is that when the organization later revoked the session, it only killed one of them.
This is fixed now. It is tracked as CVE-2026-116201 (Moderate).
Some background
When you log in through SAML, your identity provider (think Okta, Azure AD, Google Workspace) signs a short-lived assertion and your browser hands it to the application. To stop someone from grabbing that assertion off the wire and reusing it, the application is supposed to remember every assertion it has already accepted and refuse any repeat. Snipe-IT did this by recording a unique token from each assertion, called a nonce, in a database table, and checking that table before accepting a new login.
That defense is only as strong as the “have I seen this before?” check. And this one had two cracks running right through it.
What went wrong
The login code did roughly this: count how many rows in the nonce table already have this nonce; if more than zero, reject the login as a replay; otherwise log the user in and then insert the nonce.
Two structural problems turned that into a race:
The check and the insert were not atomic, and a login happened in between. Picture two requests arriving at the same moment carrying the same captured assertion. Request A counts the nonce table, sees zero, and proceeds. Request B counts the table a heartbeat later, also sees zero, because A has not inserted its row yet, and proceeds too. Both then log in. Both then insert. Two sessions, one assertion.
The nonce column had no uniqueness constraint in the database. Even when both requests reached the insert step, the database happily accepted two rows with the same nonce. The one backstop that could have caught the duplicate at the last second was not there.
if (SamlNonce::where('nonce', @$samlData['nonce'])->count() > 0) { // check
abort(400, 'Assertion has already been used');
}
// ...
if (! is_null($user)) {
Auth::login($user); // login happens between check and insert
}
// ...
$s = new SamlNonce;
$s->nonce = @$samlData['nonce'];
$s->save(); // insert, with no UNIQUE to reject a duplicate
And the migration that built the table indexed the nonce column, but not uniquely:
$table->string('nonce')->index(); // non-unique: the database will accept duplicates
If you want the technical framing: this is an authentication bypass by capture-replay (CWE-294) enabled by a time-of-check to time-of-use race (CWE-367). The window between checking and recording was wide enough, and the database permissive enough, to let the same credential through twice.
How I found it and proved it
I do this kind of research in an isolated lab, never against a live company’s data. I did not need a live identity provider to demonstrate it, because the bug lives entirely in two structural facts: the missing uniqueness constraint and the non-atomic check-then-insert.
First, the database layer. I asked the schema whether it would accept a duplicate nonce, and it did:
INSERT INTO saml_nonces (nonce, not_valid_after) VALUES ('_RACE_NONCE_42', NOW()); -- OK
INSERT INTO saml_nonces (nonce, not_valid_after) VALUES ('_RACE_NONCE_42', NOW()); -- OK, duplicate accepted
Then the application layer. I sequenced the two reads ahead of the two writes, which is exactly the ordering a real concurrent race produces under load:
Actor 1: count(nonce = X) = 0 -> passes the check
Actor 2: count(nonce = X) = 0 -> passes the check (Actor 1 hasn't inserted yet)
Actor 1: Auth::login(victim) -> session A
Actor 2: Auth::login(victim) -> session B
Actor 1: INSERT nonce = X -> succeeds
Actor 2: INSERT nonce = X -> succeeds (no UNIQUE)
Two valid sessions for the same victim, from one captured assertion.
Why this matters
The whole point of single-use assertions is that one captured login equals, at most, one session an attacker can be kicked out of. This bug turned that into one captured login equals several sessions, and the organization’s safety net, revoking the session from the identity provider’s side, only reaches one of them. The others keep running on their own clock until they expire. For a security team that relies on SSO revocation as the way to pull an attacker out, that guarantee quietly stops holding.
Capturing an assertion in the first place still takes a position on the network path, a malicious browser extension, or an assertion that leaked into a log. It is not trivial. But the replay defense is precisely the control meant to make that capture survivable, and it was not working.
Timeline
I reported this to Snipe-IT’s maintainer, Grokability, on May 2, 2026, as part of a batch of findings against version 8.4.1. It was fixed quickly, shipping in version 8.5.0. It is documented publicly as GHSA-w49g-f2jc-qxhg, assigned CVE-2026-116201.
The fix
The fix closes both cracks. The application now inserts the nonce first and treats a duplicate-key failure as the “already used” rejection, so the database itself decides atomically whether the assertion is fresh:
try {
SamlNonce::create([
'nonce' => $samlData['nonce'],
'not_valid_after' => $notValidAfter,
]);
} catch (\Exception $e) {
abort(400, 'Assertion has already been used.');
}
if (! is_null($user)) {
Auth::login($user);
}
A companion migration adds the missing uniqueness constraint to the nonce column (clearing expired rows first so the constraint can be applied cleanly), so even a future code change cannot reintroduce the duplicate.
Takeaway
A “check, then act, then record” sequence is a race waiting to happen whenever two requests can run it at once. Leaning on a count query to enforce single-use is a check you can lose; leaning on a database uniqueness constraint is a guarantee you cannot. The sturdier pattern is to make the write itself the gate, insert first and let a duplicate-key error be the rejection, so uniqueness is decided atomically by the one component that can actually serialize concurrent writes.
Reported by Brayden Arnold (@builtbybrayden). Snipe-IT is open source at github.com/grokability/snipe-it.