Editorial note: This is a fictionalized composite security case study. The company, identifiers, correspondence, dates, and reward are invented; the technical failure mode and remediation are designed to be realistic for June 2023.

At 11:47 on a Saturday night, I logged into the wrong company.

Not with a stolen password. Not with a leaked session cookie. Not with an expired reset link that somebody had forgotten to invalidate.

I used a SAML assertion signed by a key I owned, for an identity provider I had configured, inside a workspace I had created fifteen minutes earlier.

The assertion was valid.

The signature was valid.

The browser opened a different workspace.

In the first workspace, my test account was a normal member. In the second, the same email address was an administrator. The avatar was the same, the email was the same, and the small badge beside it had changed from Member to Administrator.

For about ten seconds I blamed the two browser tabs.

Then I opened /api/me.

{
  "user_id": "usr_01H3EJ9J9G5W6D1R7K0P",
  "email": "andrew+saml@example.test",
  "workspace_id": "ws_blue",
  "workspace_name": "BLUE LAB",
  "role": "administrator"
}

The SAML identity provider belonged to RED LAB.

The session belonged to BLUE LAB.

I logged out, cleared every cookie for the domain, opened a private window, and did it again.

Same result.

Then I renamed the workspaces because lab-a and lab-b had become psychologically dangerous.

RED LAB   — SAML configured — member
BLUE LAB  — password login  — administrator

I repeated the flow a third time, recorded the request, saved the response, and stopped.

My notes from that minute contain only this:

23:51 — cross-tenant session confirmed
23:52 — do not touch anything else
23:53 — write report before convincing yourself it is normal

The bug was not an XML parser trick. It was not signature wrapping. It was not a broken cryptographic primitive.

It was one missing relationship.

identity_provider.workspace_id == session.workspace_id

The distance between two companies was one ==.

The feature

The company—I’ll call it $COMPANY—runs a multi-tenant business application. Customers live in workspaces. A person can belong to several workspaces, and each workspace can configure its own SAML identity provider.

The intended flow is ordinary:

employee
   │
   ▼
company identity provider
   │  signed SAML assertion
   ▼
$COMPANY assertion consumer service
   │
   ▼
company workspace

A workspace administrator uploads identity-provider metadata: an issuer, a login URL, and a public signing certificate. When an employee signs in, $COMPANY verifies the assertion, resolves the employee, and creates a session in that workspace.

Every customer used the same callback endpoint:

https://app.$COMPANY.example/auth/saml/callback

That is normal. A shared assertion consumer service is not itself a vulnerability.

It does mean the application must answer two different questions when a SAML response arrives:

  1. Is this assertion authentic?
  2. Which customer’s trust relationship makes it authentic here?

The first answer came from the signature.

The second came from a value carried through the user’s browser.

That was the loose thread.

The thing that looked too readable

I was not hunting for a cross-tenant takeover. I was testing the SSO setup flow because it had a button labeled Test connection, and buttons that promise to test enterprise authentication tend to hide interesting state machines.

I created RED LAB, enabled an enterprise trial, and configured a small local identity provider using a key pair generated for the test. Nothing unusual happened. The metadata imported correctly. The test user signed in correctly. The application redirected back to the SSO settings page with a green success banner.

Then I looked at the browser request.

The authentication request included a RelayState parameter. That is expected: SAML applications often use it to remember local state while the browser travels to the identity provider and back.

What was less expected was how much the state wanted to introduce itself.

RelayState=eyJ3b3Jrc3BhY2VfaWQiOiJ3c19yZWQiLCJuZXh0IjoiL3NldHRpbmdzL3NzbyJ9

Base64-decoded, it became:

{
  "workspace_id": "ws_red",
  "next": "/settings/sso"
}

There was no MAC. No signature. No opaque server-side reference. Just JSON taking the scenic route through base64.

Encoding is not integrity.

Still, client-controlled RelayState does not automatically mean account takeover. The server might use it only for a harmless redirect after it has independently established the tenant from the authentication request. Plenty of applications put ugly things in state and then decline to trust them.

So I changed only one value:

- "workspace_id": "ws_red"
+ "workspace_id": "ws_blue"

I left the SAML response untouched.

The assertion was still signed by RED LAB’s identity provider. Its InResponseTo still matched the request created for RED LAB. The destination still pointed to $COMPANY’s callback. The audience still named $COMPANY’s service provider. The assertion was fresh and inside its validity window.

The callback returned:

HTTP/1.1 302 Found
Location: /workspaces/ws_blue/dashboard
Set-Cookie: session=eyJ...; Secure; HttpOnly; SameSite=Lax

I followed the redirect and saw the administrator badge.

Three explanations I wanted to be true

A good bug becomes dangerous around the moment you want it to be real. That is also the moment to become suspicious of yourself.

My first theory was stale session state. Perhaps I had already authenticated to BLUE LAB, and the callback merely sent me to the most recent workspace.

I deleted the session, cleared local storage, used a new private window, and verified that /api/me returned 401 before starting SAML. The new session still belonged to BLUE LAB.

My second theory was a cosmetic workspace switch. Some applications keep one global user session and let the interface display whichever workspace appears in the URL. Maybe the page looked like BLUE LAB, but authorization still came from RED LAB.

I requested an endpoint available only to workspace administrators:

GET /api/workspaces/ws_blue/sso/configuration

The response was 200, not 403.

I did not inspect or modify any real configuration. Both workspaces were mine, and the endpoint’s status was enough.

My third theory was an accidental role carried from the source workspace. Perhaps the application had copied my RED LAB role into the target instead of resolving the existing BLUE LAB membership.

So I reversed the roles:

RED LAB   — administrator
BLUE LAB  — member

The same SAML assertion now produced a member session in BLUE LAB.

That told me exactly where authorization came from. The application was using the assertion to choose an identity and the target workspace to choose the role.

Authentication came from one tenant.

Authorization came from another.

The seam between them had no tenant check.

Reconstructing the server from the outside

I could not see $COMPANY’s source, but the behavior narrowed the implementation to something like this:

def consume_saml(saml_response: str, relay_state: str):
    assertion = parse_saml_response(saml_response)

    pending_request = AuthnRequest.get(assertion.in_response_to)
    idp = IdentityProvider.get(pending_request.identity_provider_id)

    verify_signature(assertion, certificate=idp.certificate)
    verify_issuer(assertion, expected=idp.issuer)
    verify_audience(assertion, expected=GLOBAL_SP_ENTITY_ID)
    verify_recipient(assertion, expected=GLOBAL_ACS_URL)
    verify_time_window(assertion)

    state = json.loads(base64_decode(relay_state))
    workspace = Workspace.get(state["workspace_id"])

    user = workspace.users.get_by_email(assertion.email)
    return create_session(user=user, workspace=workspace)

Every line looks reasonable in isolation.

The request ID selects the identity-provider configuration. The configured certificate verifies the response. The issuer, audience, recipient, and time window are checked. The state restores the destination. The user is looked up inside the selected workspace.

The missing line is not glamorous:

if idp.workspace_id != workspace.id:
    raise InvalidSamlResponse("identity provider does not belong to workspace")

Without it, the system proved this:

An identity provider trusted by some $COMPANY customer signed this email address.

It needed to prove this:

The identity provider trusted by this workspace signed this subject for this login transaction.

Those sentences differ by a few words and an entire security boundary.

I brought my own valid signature

The unsettling part was that none of the cryptography failed.

I did not steal a private key. I did not alter the signed XML. I did not convince the parser to validate one assertion and consume another.

I brought my own key.

As the administrator of RED LAB, I was allowed to configure an identity provider. $COMPANY quite reasonably stored my public certificate as a trusted key for that workspace.

The vulnerable flow turned that narrow trust relationship into something closer to this:

trusted by one tenant
        ↓
trusted somewhere in the platform
        ↓
usable wherever RelayState points

An attacker who controlled any SAML-enabled workspace could issue a valid assertion containing the email address of an existing user in another workspace. If the target account was an administrator, the resulting session inherited the target workspace’s administrator role.

The attacker would not need the target’s password. Application-level MFA would not help if SAML was already treated as the completed authentication step. The feature existed specifically so the application could trust the identity provider.

The platform remembered the key.

It forgot who trusted it.

Proving severity without borrowing a victim

Cross-tenant vulnerabilities create an awkward temptation. Once you can move between two tenants you own, the next dramatic screenshot would be a real customer’s workspace.

That screenshot is unnecessary.

Two controlled workspaces were enough to demonstrate the complete authorization failure:

Assertion signed by Target workspace Matching user in target Target role Result
RED LAB IdP RED LAB yes member expected member session
RED LAB IdP BLUE LAB yes administrator unexpected administrator session
RED LAB IdP BLUE LAB no user-not-found error
modified assertion BLUE LAB yes administrator signature rejected
expired assertion BLUE LAB yes administrator time-window rejected

The last two rows mattered. They showed that the security machinery was active. The application rejected a modified assertion and an expired assertion.

Only the tenant relationship was missing.

I saved a HAR file, recorded a short video using the two lab workspaces, copied the request IDs and timestamps, invalidated the sessions, and stopped testing.

A proof of concept should make the bug undeniable while making customer impact unnecessary.

Anything beyond that is not better evidence. It is damage with cleaner screenshots.

The report

I used the least exciting title I could manage:

Cross-tenant account takeover through unbound SAML RelayState

The summary was more direct:

A SAML assertion signed by an identity provider configured for workspace A can create a session for an existing user in workspace B. The response signature, issuer, audience, recipient, time window, and request correlation all validate. The target workspace is selected separately from client-controlled RelayState and is not checked against the identity-provider configuration. A tenant administrator can therefore authenticate as a known user—including an administrator—in another workspace.

Then I included:

  • The IDs of both workspaces, with a note that I controlled them.
  • The identity-provider configuration used to sign the assertion.
  • The original and modified RelayState values.
  • The resulting session’s workspace and role.
  • A five-row test matrix showing both positive and negative controls.
  • The invariant I believed the callback needed to enforce.

I also included the sentence I would want to see first if I were reading the report at $COMPANY:

No customer workspace or customer data was accessed during testing.

The report went out at 00:26.

I closed the laptop, got into bed, and spent twenty minutes mentally inventing reasons the report would be marked informative.

The reply on Sunday morning

At 09:12, a human answered.

Not an automated receipt. Not a request for a video I had already attached.

We reproduced the behavior between two internal tenants and have escalated it to the identity team. We are temporarily restricting creation of new SAML configurations while we investigate. Existing SSO connections remain active. Please do not perform additional testing until we confirm the patch is ready.

That was the best possible response.

The exploit required an attacker-controlled identity provider to become trusted somewhere in the platform. Pausing new SAML configurations removed the easiest path to introducing a malicious key without locking existing enterprise customers out on Monday morning.

An hour later they asked one question:

Does the target workspace also need to have SAML enabled?

It did not.

BLUE LAB used password login only. The callback selected BLUE LAB from RelayState, found an existing membership by email, and created the session. The target workspace did not need its own SAML configuration because the source workspace’s identity provider had already satisfied the authentication branch.

That detail changed the shape of the incident. The bug was not a trust confusion between two SAML customers. It was a platform-wide tenant confusion reachable from any workspace allowed to configure SAML.

I answered with the controlled retest and went back to not touching it.

The patch had two parts

The first fix changed RelayState from a container of authority into a random lookup key.

Before:

{
  "workspace_id": "ws_red",
  "next": "/settings/sso"
}

After:

RelayState=8HsaQ6HtwQn8zQkHq13gR4eJr5m1uQwP

The useful state moved to a short-lived server-side transaction:

{
  "state_hash": "sha256:2fd0...",
  "workspace_id": "ws_red",
  "identity_provider_id": "idp_red",
  "authn_request_id": "_req_7c91",
  "next": "/settings/sso",
  "expires_at": "2023-06-04T00:02:00Z",
  "consumed_at": null
}

The callback no longer asked the browser which workspace should receive the session. It loaded one immutable login transaction created before authentication began.

Conceptually, the new path looked like this:

def consume_saml(saml_response: str, relay_state: str):
    transaction = consume_one_time_state(relay_state)

    workspace = Workspace.get(transaction.workspace_id)
    idp = IdentityProvider.get(transaction.identity_provider_id)

    if idp.workspace_id != workspace.id:
        raise InvalidSamlResponse("tenant binding failed")

    assertion = verify_saml_response(
        saml_response,
        certificate=idp.certificate,
        expected_issuer=idp.issuer,
        expected_audience=GLOBAL_SP_ENTITY_ID,
        expected_recipient=GLOBAL_ACS_URL,
        expected_request_id=transaction.authn_request_id,
    )

    identity = SamlIdentity.get(
        workspace_id=workspace.id,
        identity_provider_id=idp.id,
        subject=assertion.subject,
    )

    return create_session(identity.user, workspace)

The dangerous line disappeared:

workspace = Workspace.get(client_supplied_workspace_id)

The browser still carried a token, but the token contained no tenant choice. It could identify only one server-side transaction, once, for a few minutes.

The second fix changed how federated users were mapped.

The vulnerable design treated this as an identity:

andrew@example.com

The patched design treated identity as a relationship:

workspace + identity_provider + subject

An email address is an attribute. A federated identity is a statement made by a particular issuer inside a particular trust boundary.

Flatten those things into one string and the application eventually has to guess which organization supplied the truth.

Why the standard SAML checks did not save it

SAML has enough nouns to make a code review feel secure by vocabulary alone: issuer, audience, recipient, destination, subject, conditions, signature, InResponseTo, NotBefore, NotOnOrAfter.

Every one of them matters.

None of them automatically models your application’s tenant boundary.

The signature

The assertion was genuinely signed by the configured identity provider. The flaw was that a provider configured by one workspace could supply identity for another workspace.

Cryptography established who signed the message. It did not establish where that signer was authorized to speak.

The audience

All tenants shared the same service-provider entity ID. The assertion correctly said it was intended for $COMPANY.

It did not say which $COMPANY customer should receive it.

The recipient and destination

Every tenant posted to the same callback URL. Those values were correct too.

A global endpoint can prove that a response arrived at the right application while saying nothing about the right tenant inside that application.

InResponseTo

The response correctly referred to a request created for RED LAB.

The server used that request to load RED LAB’s certificate, verified the assertion, and then later allowed RelayState to choose BLUE LAB for the session.

Request correlation worked.

Tenant correlation did not.

The email address

The same email can legitimately exist in several workspaces with different roles. That is normal for consultants, contractors, and people who create test accounts they later regret.

Looking up a membership by workspace + email is not necessarily wrong.

Allowing an identity provider from a different workspace to supply that email is.

Retesting the fix

The patch was ready four days later.

The old proof of concept now failed before the assertion was used:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": "invalid_saml_response",
  "reason": "tenant_binding_failed"
}

I tried changing the workspace, the return path, and the request identifier. The state token was single-use, expired quickly, and resolved to one workspace and one identity-provider configuration on the server.

Then I replayed the original token.

{
  "error": "invalid_relay_state",
  "reason": "already_consumed"
}

Finally, I ran the ordinary login flow for RED LAB. It still worked.

That last test matters. Security patches that destroy the feature are technically effective and operationally temporary.

I sent the retest results at 18:05.

The report was closed at the highest severity tier the following week. The reward was generous. The useful part was a sentence from the engineer who owned the patch:

We added this as a cross-tenant invariant in the shared authentication test suite, not only in the SAML tests.

That is better than a screenshot of a severity label.

The regression test I hope never becomes interesting again

A fixed vulnerability is a code change. A dead vulnerability is a test.

The test should construct the forbidden relationship directly, even if the public login route no longer knows how:

def test_saml_identity_provider_cannot_cross_workspace_boundary():
    idp_red = create_identity_provider(workspace=red_workspace)
    admin_blue = create_user(
        workspace=blue_workspace,
        email="andrew+saml@example.test",
        role="administrator",
    )

    transaction = create_authn_transaction_for_test(
        workspace=blue_workspace,
        identity_provider=idp_red,
    )

    response = idp_red.sign_assertion(
        subject=admin_blue.email,
        in_response_to=transaction.request_id,
    )

    result = consume_saml(
        saml_response=response,
        relay_state=transaction.relay_state,
    )

    assert result.status == 400
    assert result.error == "tenant_binding_failed"
    assert no_session_exists_for(admin_blue)

The exact framework does not matter. The invariant does:

login_request.workspace
    == identity_provider.workspace
    == identity_mapping.workspace
    == session.workspace

Four objects.

One tenant.

Every path that creates a session should make that equality impossible to avoid.

The lesson hiding between correct checks

A few months ago, a PDF button reminded me that reachability is not authorization. A server being able to contact an internal service does not mean the user who pressed the button should inherit that reach.

SAML taught me the adjacent lesson:

Authentication is not tenancy.

A valid identity does not float above the application waiting to be dropped into whichever account has the same email address. It arrives through a specific issuer, configured by a specific organization, for a specific login transaction.

Strip away that context and a valid identity becomes a very convincing counterfeit.

The dangerous code did not look dangerous:

verify_signature(assertion)
workspace = load_workspace(relay_state)
create_session(workspace, assertion.email)

It had a signature verifier. It had issuer validation. It had request correlation. It had a workspace lookup. It had all the shapes security code is expected to have.

The bug lived between the lines.

That is what made it severe. Every local check succeeded. The application never checked the relationship between their results.

Security failures often look like missing validation.

This one had plenty of validation.

It was missing a sentence:

This identity provider belongs to this workspace.

The signature was valid. The assertion was fresh. The audience matched. The certificate was exactly the certificate the platform had been told to trust.

And it was still the wrong company.

Cryptography can prove that a key signed a statement.

It cannot prove that you asked the right key.

The lock worked perfectly.

It was bolted to the wrong door.