CSIPE

Published

- 37 min read

CVE-2026-59208: The OAuth Mix-Up Attack That Automation Platforms Keep Rebuilding


The Digital Fortress: Your Everyday Guide to a Safer Digital Life

Stay Safe Online Without Making It Your Second Job

The Digital Fortress (Second Edition)

A warm, plain-English guide for people with real lives and finite patience. Learn the handful of habits that genuinely protect your money, accounts, and family, and get honest permission to ignore the rest.

Buy the book now
The Anonymity Playbook: Digital Survival for Whistleblowers, Journalists, Activists, and Everyone Else

For People Who Cannot Afford to Get Privacy Wrong

The Anonymity Playbook (Second Edition)

A practitioner’s field manual for journalists protecting sources, whistleblowers, and activists. It explains how the surveillance actually works, what each technique costs you, and exactly where it fails.

Buy the book now
Secure Software Development: Practical patterns for building secure software

Write, Ship, and Maintain Code Without Shipping Vulnerabilities

Secure Software Development

A hands-on security guide for developers and IT professionals who ship real software. Build, deploy, and maintain secure systems without slowing down or drowning in theory.

Buy the book now
The Secure Harness: Shipping Production Code with AI Coding Agents

Use AI Coding Agents Without Losing Control of Your Codebase

The Secure Harness

A calm, practical guide to letting agents do useful work inside boundaries you set, enforce, and audit. Ships with 15 copy-pasteable artifacts: hook scripts, permission configs, release gates, and MCP templates.

Buy the book now
The AI Native Engineer: Build, Evaluate, and Ship AI Systems That Work in Production

Stop Shipping Demos. Start Shipping Systems.

The AI Native Engineer

Sixteen hands-on chapters, one real product. Grow it from a single model call into a retrieved, tool-using, observable, production-grade system, with evaluation treated as a habit from the first feature.

Buy the book now

The bug in one sentence

In the n8n automation platform, tracked as CVE-2026-59208, the token exchange endpoint verified that a subject token carried a valid signature from a trusted key, and never checked that the key belonged to the issuer the token claimed to come from. Combined with account resolution by name, that gap let an identity from one tenant resolve into an account in another.

The sentence is short and the failure is easy to reproduce in your own code, which is the reason to write about it. Signature validity and issuer identity are different properties. A system that checks the first and infers the second has a cross-tenant authentication bypass, and the code will look correct in review because it does contain a signature check.

Walking the attack end to end

That description compresses several steps into one sentence, so here is the same failure at the pace an attacker actually experiences it.

Start with the attacker’s position. They are a paying customer of the platform, sitting on their own tenant, and the platform lets them point their workflows at their own identity provider. That is a feature rather than a misconfiguration; self-service SSO is table stakes for anything sold to enterprises. The moment their provider’s signing key lands in the platform’s trusted set, they hold a key the verifier will accept.

Everything after that is arithmetic. A token gets minted with their own key, because it is their key and their provider. The iss claim is set to the URL of a different tenant’s identity provider, a value that is often readable from a public metadata endpoint or guessable from the customer’s domain. Then sub or email gets a name that exists in the target tenant. Post it to the token exchange endpoint and wait.

Victim tenant AAccount resolverPlatform verifierAttacker's own IdPAttacker (tenant B)Victim tenant AAccount resolverPlatform verifierAttacker's own IdPAttacker (tenant B)iss = sso.tenant-a.example, sub = adminattacker key is in the set, signature validatesmint JWT with own signing keyPOST /token-exchange (subject_token)for key in TRUSTED_KEYS, try verifysignature OKlookup account by name "admin"user 42, tenant Aaccess token for tenant A adminread stored credentials, run workflows

Three properties combine here and none of them is dangerous alone. Accepting several identity providers is normal. Verifying signatures against the keys you trust is normal. Resolving accounts by a human-readable name is normal. The composition is what fails, which is why the bug survives review: each reviewer checks the line in front of them, and each line is defensible.

The remediation is equally undramatic. Bind the key to the issuer, and bind the account to the pair. Neither change requires new cryptography, a protocol upgrade, or a coordinated release with your identity provider. Both get deferred because the code already “has a signature check”, which is the sentence to listen for in a design review.

One detail worth internalising: the attacker never breaks a signature. Every cryptographic operation in the flow succeeds. Monitoring built around failed verifications sees nothing, and so does a load test, a fuzzer pointed at the parser, and any tool that treats a 200 response as evidence of correct behaviour. The failure is semantic, and semantic failures need semantic tests.

Trusted keys are a set, not a subject

Most token verification code holds a collection of trusted keys. In a single-issuer deployment that collection has one entry, the check is unambiguous, and everything works.

Multi-issuer deployments break the assumption quietly. Now the trusted set contains keys from several identity providers: your enterprise SSO, a partner’s directory, a per-tenant provider each customer configured themselves. A token arrives, the verifier walks the set looking for a key that validates the signature, finds one, and returns success. The token’s iss claim is read afterwards, if at all, and used to look up which account this identity maps to.

The flaw is that any holder of any trusted key can mint a token claiming any issuer. A partner who legitimately configured their own identity provider signs a token with their own key, sets iss to your enterprise provider’s URL and sub to an administrator’s identifier, and the verifier confirms the signature is valid because it is. It just is not valid for that issuer.

   # Wrong. The signature is checked against every trusted key,
# and the issuer is read from the token afterwards.
def verify(token: str) -> dict:
    for key in TRUSTED_KEYS:                    # any key will do
        try:
            claims = jwt.decode(token, key, algorithms=["RS256"])
            return claims                       # iss is never bound to key
        except jwt.InvalidSignatureError:
            continue
    raise AuthError("no trusted key validated this token")

The correct version resolves the key from the issuer, so the binding is enforced by construction:

   # Right. The claimed issuer selects the key set, and an unknown
# issuer fails before any signature work happens.
ISSUER_KEYS = {
    "https://sso.example.com":        sso_jwks,
    "https://partner-idp.example.net": partner_jwks,
}

def verify(token: str, expected_audience: str) -> dict:
    unverified = jwt.decode(token, options={"verify_signature": False})
    issuer = unverified.get("iss")
    jwks = ISSUER_KEYS.get(issuer)
    if jwks is None:
        raise AuthError(f"unknown issuer: {issuer!r}")

    claims = jwt.decode(
        token,
        jwks,                       # only this issuer's keys
        algorithms=["RS256"],
        audience=expected_audience, # and the token must be meant for us
        issuer=issuer,
    )
    return claims

Reading unverified claims to select a key looks alarming and is safe here, because the issuer value only chooses which key set to attempt. A forged issuer selects keys that will fail to validate the signature.

A verifier that survives key rotation

Those two samples make the point and skip everything an operator has to care about: where the keys come from, how long they are cached, and what happens at 4am when a provider rotates. Those details are where correct code decays back into the vulnerable shape, so they are worth writing out in full.

The rules the implementation below enforces:

  • The claimed issuer must appear in a registry you control. Unknown issuers fail before any signature work happens.
  • Keys come from that issuer’s JWKS endpoint, pinned at registration and cached with a TTL.
  • A kid miss triggers at most one refetch, rate limited, and only against that issuer’s endpoint.
  • Permitted algorithms are configured per issuer and never read from the token header.
  • Every claim the application depends on is required rather than optional.
   import threading, time
import jwt
from jwt import PyJWKClient

class IssuerRegistry:
    """issuer -> policy. Populated from config or an admin-reviewed
    table, never from a value found inside a token."""

    def __init__(self, config: dict, jwks_ttl: int = 600):
        self._lock = threading.Lock()
        self._policy, self._clients, self._fetched_at = {}, {}, {}
        self._ttl = jwks_ttl
        for issuer, policy in config.items():
            if not issuer.startswith("https://"):
                raise ValueError(f"issuer must be https: {issuer!r}")
            self._policy[issuer] = {
                "jwks_uri": policy["jwks_uri"],   # pinned, not discovered
                "algorithms": policy.get("algorithms", ["RS256"]),
                "tenant_id": policy["tenant_id"],
            }

    def policy_for(self, issuer):
        # Exact match only. No prefix, suffix, or normalisation games.
        return self._policy.get(issuer)

    def signing_key(self, issuer: str, token: str):
        with self._lock:
            client = self._clients.get(issuer)
            age = time.time() - self._fetched_at.get(issuer, 0)
            if client is None or age > self._ttl:
                client = PyJWKClient(
                    self._policy[issuer]["jwks_uri"],
                    cache_keys=False,        # cache lives here, keyed by issuer
                    timeout=3,
                )
                self._clients[issuer] = client
                self._fetched_at[issuer] = time.time()
        return client.get_signing_key_from_jwt(token).key


REGISTRY = IssuerRegistry({
    "https://sso.example.com": {
        "jwks_uri": "https://sso.example.com/.well-known/jwks.json",
        "algorithms": ["RS256"],
        "tenant_id": "acme",
    },
})


def verify(token: str, expected_audience: str) -> dict:
    header = jwt.get_unverified_header(token)
    if header.get("jku") or header.get("jwk") or header.get("x5u"):
        raise AuthError("token carries its own key material")

    unverified = jwt.decode(token, options={"verify_signature": False})
    issuer = unverified.get("iss")
    if not isinstance(issuer, str):
        raise AuthError("missing or non-string iss")

    policy = REGISTRY.policy_for(issuer)
    if policy is None:
        raise AuthError(f"unknown issuer: {issuer!r}")

    key = REGISTRY.signing_key(issuer, token)   # only this issuer's keys

    claims = jwt.decode(
        token,
        key,
        algorithms=policy["algorithms"],        # per issuer, from config
        audience=expected_audience,
        issuer=issuer,
        leeway=30,
        options={"require": ["exp", "iat", "iss", "aud", "sub"]},
    )
    claims["_tenant_id"] = policy["tenant_id"]  # tenant from config, not token
    return claims

Two lines carry more weight than their length suggests. Rejecting jku, jwk, and x5u headers stops a token from nominating the key that verifies it, a bypass that shipped in real libraries; CVE-2018-0114 in Cisco’s node-jose is the reference case, where an embedded JWK in the header was honoured. And keeping the cache keyed by issuer rather than by kid means two providers that happen to choose the same key identifier cannot borrow each other’s keys.

yes

no

no

yes

no

no

yes

yes

no

yes

no

yes

Token arrives

Header names jku, jwk, or x5u?

Reject: self-supplied key

Read iss without verifying

iss in registry, exact match?

Reject: unknown issuer

Load that issuer's JWKS

kid present in this issuer's keys?

Refetch once, rate limited

found?

Reject: unknown kid

Verify signature

alg in this issuer's allowlist?

Reject: algorithm not permitted

aud, exp, iat, sub present and valid?

Reject: claim validation failed

Return claims plus tenant

The shape to keep is a single arrow running from issuer to key set. Every bug in this family comes from a code path where that arrow points the other way, forks, or is missing.

Name collision finishes the job

Issuer confusion produces a token that verifies. Turning that into access requires the application to map the identity onto an account, and n8n’s second weakness lived in that mapping: resolution by account name.

Identity resolution should key on a value that is unique within the issuer’s namespace, and the pair (iss, sub) is the standard construction for exactly this reason. A sub of admin from your SSO and a sub of admin from a partner directory are different principals that happen to share a string. Systems that look up by name alone collapse them into one.

The same class of bug appears in email-based resolution, which is more common in practice. Two identity providers can both assert [email protected], and only one of them has any authority over that domain. A platform that trusts whichever provider asserted it first, or last, has delegated account takeover to every configured provider.

   -- Vulnerable: name is not unique across issuers.
SELECT id FROM users WHERE username = :name;

-- Correct: the issuer is part of the identity.
SELECT id FROM users WHERE issuer = :iss AND subject = :sub;

Retrofitting this onto a live system is the hard part, because existing rows have no issuer recorded. The migration path that works is to add the columns, backfill from whatever provider was in use when each account was created, make the pair unique, and only then switch the lookup. Doing it in the other order locks users out.

The compound identity migration, phase by phase

That last paragraph is the whole plan in one sentence, and the sentence hides about a week of work. Here is the version with the sharp edges marked.

Phase 1: add the columns, write both. Add nullable issuer and subject columns, change every login path to populate them alongside the existing name field, and change no reads at all. After one full session lifetime has elapsed, most active accounts are populated for free.

   ALTER TABLE users ADD COLUMN issuer  text;
ALTER TABLE users ADD COLUMN subject text;
CREATE INDEX CONCURRENTLY users_issuer_subject_idx
    ON users (issuer, subject);

Phase 2: backfill from provenance, not from guesswork. The correct issuer for an existing row is whichever provider that account actually authenticated through, and the only reliable source is your own records: auth events, the SSO configuration active at signup, a created_via column if you were lucky enough to have one. On a deployment that has only ever had a single provider, the backfill is a constant.

   UPDATE users u
SET issuer  = a.issuer,
    subject = a.subject
FROM (
    SELECT DISTINCT ON (user_id) user_id, issuer, subject
    FROM auth_events
    WHERE issuer IS NOT NULL
    ORDER BY user_id, occurred_at DESC
) a
WHERE u.id = a.user_id
  AND u.issuer IS NULL;

Phase 3: find the collisions before the constraint does. Run the uniqueness check as a query first. Any duplicate pair is a pre-existing account merge problem, and a constraint will surface it as a failed migration at the worst possible moment.

   -- Duplicates under the new key.
SELECT issuer, subject, count(*), array_agg(id)
FROM users
WHERE issuer IS NOT NULL
GROUP BY issuer, subject
HAVING count(*) > 1;

-- The more interesting query: names that already exist
-- under more than one issuer.
SELECT lower(username), count(DISTINCT issuer), array_agg(DISTINCT issuer)
FROM users
WHERE issuer IS NOT NULL
GROUP BY lower(username)
HAVING count(DISTINCT issuer) > 1;

If that second query returns rows on a system currently resolving by name, you have found live evidence of the bug rather than a theoretical exposure, and the incident starts now.

Phase 4: constrain, then switch the read. Only after the collision queries come back clean.

   ALTER TABLE users
    ALTER COLUMN issuer  SET NOT NULL,
    ALTER COLUMN subject SET NOT NULL;

ALTER TABLE users
    ADD CONSTRAINT users_identity_unique UNIQUE (issuer, subject);

Then, and only then, change the lookup to key on the pair. The ordering matters because a NOT NULL constraint applied before the backfill completes takes the login path down, and a read switch applied before the constraint exists lets a race create a second row for the same identity.

Phase 5: remove the fallback. Migrations of this shape almost always ship with a compatibility branch: if no row matches (iss, sub), fall back to the name lookup and adopt the row. That branch is the vulnerability, kept alive behind a feature flag. Give it an expiry date in the same pull request that adds it, emit a counter every time it fires, and delete it when the counter has read zero for a month. A fallback nobody measures is a fallback nobody removes.

One caveat for platforms with a self-service SSO story: a tenant that changes identity provider produces legitimate (iss, sub) churn, and users will show up with a new pair and an old account. Handle that with an explicit, audited relink operation performed by a tenant administrator. Handling it by silently falling back to name matching puts you back where you started, with a nicer function name.

What RFC 9207 fixes and what it does not

RFC 9207 addresses a related mix-up in the authorization code flow by requiring the authorization server to return an iss parameter alongside the code, so the client can confirm which server actually responded. Before it, a client that supported multiple providers could be tricked into sending a code issued by one server to a different server’s token endpoint.

Supporting it is straightforward and worth doing. The client records which issuer it started the flow with, and compares:

   def handle_callback(request, session):
    if request.args.get("state") != session["state"]:
        raise AuthError("state mismatch")

    # RFC 9207: the response must identify its issuer, and it must
    # be the one we started with.
    returned_iss = request.args.get("iss")
    if returned_iss != session["issuer"]:
        raise AuthError(
            f"issuer mismatch: began with {session['issuer']!r}, "
            f"response claims {returned_iss!r}"
        )
    ...

The limitation is scope. RFC 9207 covers the front-channel authorization response. The n8n flaw was in back-channel token exchange, a different endpoint governed by RFC 8693, where no equivalent parameter exists because the security property is supposed to come from the verifier binding keys to issuers correctly. Implementing 9207 and assuming token exchange is now covered would leave the actual bug in place.

The mix-up family, variant by variant

Since 9207 covers exactly one member of a family, the useful exercise is laying the family out and marking which document addresses each branch. Confusion attacks share a single shape: a value that identifies who is talking gets separated from the value that proves it.

VariantWhere it livesWhat the attacker suppliesWhat closes it
Classic mix-upAuthorization responseA code from AS-A delivered to a client that believes it is talking to AS-BRFC 9207 iss parameter, or a distinct redirect URI per issuer
Code injectionAuthorization responseA code obtained elsewhere, pasted into a victim’s sessionPKCE (RFC 7636) with a per-session verifier, plus nonce for OIDC
Issuer confusion in verificationAny JWT verifierA validly signed token claiming somebody else’s issKey set selected by issuer, exact match, per-issuer algorithms
Algorithm confusionAny JWT verifieralg: none, or HS256 signed with the published RSA public keyExplicit algorithm allowlist per issuer, never the header value
Key nominationJWT headerjku, jwk, or x5u pointing at attacker key materialReject those headers outright
Audience confusionAny resource serverA token minted for service X replayed against service YMandatory aud check, resource indicators (RFC 8707)
Subject substitution at the STSToken exchange endpointSomebody else’s leaked subject tokenClient authentication on the exchange, plus the may_act claim
Metadata spoofingDiscoveryA crafted .well-known document for a tenant-registered issuerAdmin review of issuer registration, JWKS URI pinned at registration

Read the second column twice, because it is the interesting one. Four of these live inside a verifier that somebody wrote once and has not opened since, and the fixes are library configuration rather than protocol work. Two live at the edges of a flow most teams delegate to an SDK, which is why they get less attention and also why they get fixed for free on an upgrade. One, the STS variant, only exists on platforms doing delegation, which is to say the platforms that agents are currently being bolted onto.

Algorithm confusion deserves its own note because it keeps coming back. It is the oldest bug in this space, publicly demonstrated against node-jsonwebtoken a decade ago (CVE-2015-9235), and it still lands: fast-jwt shipped a variant tracked as CVE-2023-48223 where the guard failed for some public key types. Even the primitive underneath has failed, as Java’s ECDSA verification did in CVE-2022-21449, where a signature of all zeroes verified against any key. The defence has never changed. Pass the algorithm list in, and treat any code path where the library infers the algorithm as a finding.

RFC 9700, the security best current practice for OAuth 2.0, collects the front-channel guidance and is the document to hand a team still running implicit flow or wildcard redirect URIs. OAuth 2.1 folds the same conclusions into one readable spec: PKCE on every authorization code flow, exact string matching on redirect URIs, no implicit grant, no password grant.

What none of those documents tell you is how your verifier resolves a key. The gap is deliberate, since the protocol assumes the verifier already knows which keys belong to which issuer. The n8n advisory is the case where that assumption was wrong, and it went unwritten precisely because it seems too obvious to state.

Why agent platforms concentrate this risk

Automation and agent platforms make identity bugs unusually expensive, and it is worth being explicit about why.

These systems exist to hold credentials for other systems. A workflow platform with a hundred integrations is storing OAuth tokens for a hundred services per tenant, and it needs them to be long-lived because workflows run unattended at 3am. Compromising one account there yields a credential set that would otherwise take a hundred separate phishing campaigns.

They also run untrusted logic by design. A workflow is code, authored by a user, executing with the platform’s access to those stored credentials. The security model depends entirely on the tenant boundary holding, which puts unusual weight on the identity layer being correct.

Agents add a third property: they make requests on behalf of a user, to services that see the platform’s identity rather than the user’s. Every one of those hops is a place where the question “who is actually asking for this” can lose its answer, and the industry does not yet have settled patterns for propagating agent identity across them.

Writing the exchange endpoint

Given that concentration of risk, the exchange endpoint deserves to be written out rather than described, because it is the piece most teams build themselves and the piece with the least available prior art.

RFC 8693 defines the request shape. A client posts to the token endpoint with grant_type set to urn:ietf:params:oauth:grant-type:token-exchange, supplies a subject_token with its subject_token_type, and optionally an actor_token, an audience or resource, a scope, and a requested_token_type. The response carries access_token, a required issued_token_type, a token_type (the literal N_A when the result cannot be used as an access token), and ideally a short expires_in. The parts the spec leaves to the implementer are the parts that decide whether the endpoint is a security control or a credential vending machine.

   GRANT       = "urn:ietf:params:oauth:grant-type:token-exchange"
ACCESS_TYPE = "urn:ietf:params:oauth:token-type:access_token"

AUDIENCE_POLICY = {
    # audience -> the complete set of scopes it will ever accept
    "https://billing.example.com": {"refund:create", "invoice:read"},
    "https://tickets.example.com": {"ticket:read", "ticket:comment"},
}

MAX_ACT_DEPTH = 3
EXCHANGED_TTL = 120   # seconds


def token_exchange(request, authenticated_client):
    if request.form["grant_type"] != GRANT:
        return oauth_error("unsupported_grant_type")

    # 1. Subject token goes through the issuer-bound verifier. The
    #    audience here is the STS itself, not the downstream service.
    subject = verify(request.form["subject_token"], STS_AUDIENCE)

    # 2. The actor token, when present, gets the same treatment and
    #    identifies the agent making the request.
    actor = None
    if "actor_token" in request.form:
        actor = verify(request.form["actor_token"], STS_AUDIENCE)

    actor_id = actor["sub"] if actor else authenticated_client.agent_id

    # 3. The subject must have authorised this actor. RFC 8693's may_act
    #    claim carries that statement inside the subject token.
    may_act = subject.get("may_act")
    if may_act and may_act.get("sub") != actor_id:
        return oauth_error("invalid_request", "actor not permitted for subject")

    # 4. The audience must be one we know about. Anything else is a
    #    target we refuse to mint for.
    audience = request.form.get("audience")
    if audience not in AUDIENCE_POLICY:
        return oauth_error("invalid_target")

    # 5. Scope only ever narrows: the intersection of what was asked
    #    for, what the subject holds, and what the audience accepts.
    requested = set(request.form.get("scope", "").split())
    held      = set(subject.get("scope", "").split())
    granted   = requested & held & AUDIENCE_POLICY[audience]
    if not granted:
        return oauth_error("invalid_scope")

    # 6. Delegation chain: nest the previous act claim inside the new one.
    act = {"sub": actor_id}
    if "act" in subject:
        act["act"] = subject["act"]
    if act_depth(act) > MAX_ACT_DEPTH:
        return oauth_error("invalid_request", "delegation chain too long")

    now = int(time.time())
    claims = {
        "iss": STS_ISSUER,
        "sub": subject["sub"],            # still the human
        "act": act,                       # who is driving
        "aud": audience,                  # one service, not a list
        "scope": " ".join(sorted(granted)),
        "client_id": authenticated_client.id,
        "iat": now,
        "exp": now + EXCHANGED_TTL,
        "jti": uuid4().hex,
    }
    token = jwt.encode(claims, STS_PRIVATE_KEY, algorithm="RS256",
                       headers={"kid": STS_KID, "typ": "at+jwt"})

    audit.record("sts.exchange", sub=claims["sub"], act=actor_id,
                 aud=audience, scope=claims["scope"], jti=claims["jti"])

    return {
        "access_token": token,
        "issued_token_type": ACCESS_TYPE,
        "token_type": "Bearer",
        "expires_in": EXCHANGED_TTL,
        "scope": claims["scope"],
    }

Five decisions in that function are worth defending out loud. Client authentication is mandatory, because an unauthenticated exchange turns any leaked subject token into a token factory; RFC 8693 makes the same argument in its security considerations, noting that client authentication is what lets the STS decide which entities may receive delegations at all. The audience is a single string rather than an array, which deletes the “did we check every entry” question from every downstream verifier. Scope is an intersection and never a union, so an exchange can only narrow authority. The lifetime is two minutes, because an exchanged token exists to cross one hop. And the audit record is written before the token is returned, since a credential that exists without a log line is a credential nobody can explain later.

The sub deliberately stays the user. Swapping it for the agent’s identifier produces impersonation semantics, which the RFC permits and which discards the exact information a downstream service needs in order to make a decision. Impersonation is the right choice only when a downstream system cannot be taught to read act, and it should be recorded as a known gap rather than a design.

The neighbouring mistakes in the same code

Issuer binding is the flaw that got a CVE. Four others live in the same twenty lines and appear together often enough to be worth checking in one pass.

Algorithm confusion is the classic. A verifier that accepts the algorithm named in the token header will accept alg: none, or will accept an RS256 public key used as an HS256 shared secret, since the public key is public. Always pass an explicit algorithm list to the decoder and never read the algorithm from the token.

Missing audience validation lets a token minted for one service authenticate to another. In an ecosystem with several internal services sharing an identity provider, a token your analytics service legitimately received can be replayed against your admin API unless each service checks that it is the intended recipient.

Expiry handling gets weakened in ways that persist. A clock-skew allowance of a few seconds is reasonable; the ten-minute allowance somebody added during a debugging session is a ten-minute replay window that nobody revisits. Similarly, verifiers that treat a missing exp claim as “no expiry” rather than as invalid accept immortal tokens.

Key rotation handled by widening the trusted set is how the original bug gets reintroduced. When a provider rotates keys, the correct behaviour is fetching current keys from that issuer’s JWKS endpoint with sensible caching. Appending the new key to a global list, and never removing the old one, rebuilds exactly the searched-key-set pattern that caused the problem.

   # The decoder call carrying every check that should be there.
claims = jwt.decode(
    token,
    jwks,                          # keys resolved from the claimed issuer
    algorithms=["RS256"],          # explicit, never from the header
    audience="https://billing.example.com",
    issuer=issuer,
    leeway=30,                     # seconds, not minutes
    options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)

The require option is the cheapest of these to add and catches the largest class of mistake, because it turns a missing claim into a rejection rather than into a default.

Every claim, what it protects, and how it fails

The require list is a sensible default, and knowing why each entry earned its place makes the list defensible when somebody asks to shorten it for a legacy integration.

Claim or headerCheckFailure mode when skipped
issExact string match against a registry, which then selects the key setAny trusted key signs for any issuer, which is CVE-2026-59208
alg (header)Must appear in this issuer’s configured allowlistalg: none and the RS256 to HS256 downgrade
kid (header)Must resolve within the claimed issuer’s JWKSCross-issuer key borrowing when two providers share a kid
jku / jwk / x5uReject the token outright if any are presentThe token supplies the key that verifies it
audMust equal this service’s own identifierA token for the analytics API opens the admin API
subRequired, and combined with iss for account lookupName collisions across tenants resolve into the wrong account
expRequired, with leeway measured in secondsImmortal tokens, or a replay window as wide as the leeway
iatRequired, and reject tokens issued far in the futurePre-minted tokens outlive the rotation you thought closed them out
nbfHonour it when presentTokens usable before their intended activation time
jtiRecord it for replay detection on high-value operationsOne captured exchanged token replayed across its whole scope
azp / client_idCompare against the authenticated clientA token issued to one client presented by another
scopeIntersect with policy, never accept as already grantedPrivilege escalation through a generously requested scope
actRequired when the caller is an agent, and walk the chainAn agent’s request is indistinguishable from the user’s own
may_actEnforce at the exchange endpointAny agent can act for any user whose token it obtains
typ (header)Expect at+jwt on access tokensAn ID token accepted where an access token belongs
cnfVerify proof of possession whenever you issue itA sender-constrained token accepted as a plain bearer token

Two rows deserve a sentence each. The typ header check is the cheapest defence against ID token confusion, where a token minted to tell your frontend who logged in gets presented to an API as authorisation; the two have different audiences and different lifetimes, and the check costs one string comparison. The iat row covers the signal people forget: a provider with a healthy clock does not issue tokens dated next Tuesday, so a forward-dated iat means either a broken client or somebody stockpiling credentials ahead of a rotation.

The one entry worth arguing about is jti replay detection, which needs storage and a retention decision. Applying it to every request costs more than it returns. Applying it to exchanged tokens gives you a small, short-lived set and catches the case that actually matters: an agent’s downstream credential captured in a log and replayed by whoever reads that log.

Anti-patterns that survive code review

Each of the following has passed review somewhere, usually because the diff was three lines and the reasoning sounded fine at the time.

Prefix matching on the issuer. issuer.startswith("https://sso.example.com") accepts https://sso.example.com.attacker.net, and issuer.endswith("example.com") accepts https://notexample.com. Issuer comparison is exact equality, after a trailing-slash decision you make once and write down.

Normalising before comparing. Lowercasing, stripping trailing slashes, resolving redirects, or following a discovery document to find the “real” issuer each open a gap between the string that selected the key and the string that was signed. Compare the bytes you were handed.

A shared JWKS cache keyed by kid. Subtle and common. A cache mapping kid to key, filled from every configured provider, rebuilds the searched key set from the vulnerable sample with extra steps and a nicer API. Key identifiers are chosen independently by each provider, so collisions are neither rare nor detectable from the token. Key the cache on (issuer, kid).

Falling back on a kid miss. “If we cannot find the key by kid, try all of this issuer’s keys” is acceptable and sometimes necessary during rotation. “Try every key we know” is the bug.

Trusting email without email_verified and domain authority. A provider can assert any address it likes. The question your code has to answer is whether this particular provider has authority over that domain, and the answer belongs in tenant configuration rather than in the token.

Letting tenants register issuers unattended. Self-service SSO is a reasonable product decision that also turns every customer into a party whose signing key your verifier accepts. With correct verification logic, fine. With any of the flaws above, self-service registration hands the exploit to your entire customer base. Make registration an administrative action with a human reviewer, and pin the JWKS URI at registration time.

Relaying the user’s token downstream unchanged. Passing the incoming token along is faster to build than an exchange, and it gives every service in the chain a credential valid for every other service. The first service to log a request body leaks a token that works everywhere.

Caching exchanged tokens for reuse. A two-minute token that a well-meaning optimisation caches for an hour is an hour-long token wearing a two-minute label. If the exchange is too slow to run per call, fix the exchange.

Logging the token to debug the token. log.info("verifying %s", token) ships that credential to your log aggregator, your error tracker, and everyone with read access to either. Log the jti, the iss, the sub, and the outcome.

Treating the exchange endpoint as internal. Internal endpoints get reached, through SSRF, through a compromised workflow, through a service mesh rule somebody widened on a Friday. An endpoint that mints credentials on demand deserves the same authentication, rate limiting, and alerting as your login page.

Propagating identity through an agent

The unsolved part deserves more than a passing mention, because teams are shipping into it right now without settled guidance.

When a user asks an agent to do something and the agent calls three services to accomplish it, there are two identities in play and both matter. The user authorised the outcome. The agent performed the action. A downstream service that sees only the platform’s service account cannot tell whether this request reflects a user’s intent or a compromised workflow, and it cannot apply the user’s own permissions.

The common shortcut is to give the agent a service account with the union of every permission any user might need. It works immediately and it means a single prompt injection reaches everything the platform can touch, for every tenant. The blast radius is the entire permission set rather than one user’s slice.

Token exchange as specified in RFC 8693 exists to solve exactly this. The agent presents the user’s token and receives a downstream token that carries the user’s identity, scoped to the specific service and action, with a short lifetime. Done properly, a compromised workflow acting for one user can reach only what that user could reach. The n8n flaw is a reminder that the mechanism carries its own risk, since the exchange endpoint becomes a high-value target that mints credentials on demand.

A pattern that holds up in review is to include both identities in the exchanged token and let downstream services see the chain:

   {
	"iss": "https://sso.example.com",
	"sub": "user-8831",
	"act": { "sub": "agent://support-triage/v3" },
	"aud": "https://billing.example.com",
	"scope": "refund:create",
	"exp": 1785000000
}

The act claim is the actor: it records that the agent performed this on the user’s behalf. A billing service can then enforce that refunds require both a user with refund rights and an agent authorised to issue them, and an audit log can answer “who did this” without ambiguity. Rate limits can key on the agent while permissions key on the user, which is usually what you actually want.

Scoping down, one tool call at a time

Carrying both identities answers “who did this”. The harder question is how little authority each hop should carry, and the honest answer is smaller than most implementations start with.

A useful discipline: the token an agent presents to a tool is minted for that tool, for that call, with the scopes that call needs and nothing more. If the agent is about to issue a refund, it exchanges for a token carrying refund:create, scoped to the billing service, with a lifetime measured against the call rather than the session. The next tool call gets its own exchange. A prompt injection that redirects the agent mid-run then holds a credential for the previous tool and the previous scope, which is a bad afternoon instead of a breach notification.

Audit logBilling serviceToken exchange (STS)Agent runtimeUserAudit logBilling serviceToken exchange (STS)Agent runtimeUserholds user token, sub=user-8831"refund order 4471"1exchange(subject=user token, actor=agent token,2audience=billing, scope=refund:create)3verify both tokens, issuer-bound4check may_act, intersect scope5sub=user-8831, act=agent, aud=billing, exp=+120s6POST /refunds with Bearer token7verify iss, aud, exp, require act claim8user may refund AND agent may refund9201 Created10refund by user-8831 via support-triage/v311done12

The patterns teams actually pick, side by side:

PatternBlast radius on compromiseDownstream can tell it was an agentPer-user permissions applyReasonable when
Shared service accountEverything the platform can reach, every tenantNoNoNever, on a multi-tenant platform
Per-tenant service accountOne tenant, every user in itNoNoBatch jobs with no user context
Impersonation, sub replacedOne userNoYesLegacy downstreams that cannot read act
Delegation, sub plus actOne user, one agentYesYesThe sane default for agent platforms
Delegation scoped per callOne user, one agent, one operationYesYesHigh-value operations, anything a tool exposes
User token relayed unchangedEvery service in the chainNoYesNever

Downstream services carry the other half of the contract, and their obligations are worth writing into an internal standard, because a delegation chain is only as good as its last link. Verify iss and aud through the issuer-bound verifier, the same code path as everything else in the estate. Require an act claim on any request arriving from the agent platform, and reject requests without one rather than quietly treating them as direct user traffic. Evaluate both principals, with the effective permission being the intersection: the user must hold the right, and the agent must be authorised for that class of operation. Cap the chain depth and refuse anything deeper, since a chain that keeps growing usually indicates a loop rather than a legitimate hop. And treat the agent identifier as versioned, so agent://support-triage/v3 and v4 are separate principals; a rollback should narrow authority rather than silently widen it.

The property worth protecting is that every request can answer three questions independently: which human authorised this outcome, which piece of software performed the action, and which single operation this credential permits. Any design that collapses two of those into one identifier has given up the ability to answer the third.

Tests a reviewer should require

All of the above is enforceable at review time, and the enforcement mechanism is a test file rather than a checklist in a wiki. Negative tests carry the weight here, since the positive path worked before the bug existed and kept working afterwards.

Set up two issuers with independent key pairs, then assert that keys and issuers cannot be mixed.

   import time, jwt, pytest

ISSUER_A = "https://sso.example.com"
ISSUER_B = "https://partner-idp.example.net"
AUD      = "https://api.example.com"

def mint(key, *, iss, sub="user-1", aud=AUD, alg="RS256", exp=None, **extra):
    now = int(time.time())
    return jwt.encode({"iss": iss, "sub": sub, "aud": aud, "iat": now,
                       "exp": exp or now + 300, **extra}, key, algorithm=alg)


def test_valid_token_from_registered_issuer_verifies():
    claims = verify(mint(KEY_A_PRIVATE, iss=ISSUER_A), AUD)
    assert claims["iss"] == ISSUER_A


# The CVE-2026-59208 test: fails on vulnerable code, passes on fixed code.
def test_issuer_b_key_cannot_sign_for_issuer_a():
    forged = mint(KEY_B_PRIVATE, iss=ISSUER_A, sub="admin")
    with pytest.raises(AuthError):
        verify(forged, AUD)


def test_unknown_issuer_rejected_before_any_key_fetch(monkeypatch):
    calls = []
    monkeypatch.setattr(REGISTRY, "signing_key", lambda *a: calls.append(a))
    with pytest.raises(AuthError):
        verify(mint(KEY_B_PRIVATE, iss="https://evil.example"), AUD)
    assert calls == []


@pytest.mark.parametrize("iss", [
    "https://sso.example.com/",            # trailing slash
    "https://sso.example.com.evil.net",    # prefix match
    "https://SSO.example.com",             # case variation
    "http://sso.example.com",              # scheme downgrade
    " https://sso.example.com",            # leading whitespace
])
def test_near_miss_issuers_rejected(iss):
    with pytest.raises(AuthError):
        verify(mint(KEY_A_PRIVATE, iss=iss), AUD)


def test_alg_none_rejected():
    token = jwt.encode({"iss": ISSUER_A, "sub": "admin"}, None, algorithm="none")
    with pytest.raises(AuthError):
        verify(token, AUD)


def test_public_key_as_hmac_secret_rejected():
    now = int(time.time())
    forged = jwt.encode({"iss": ISSUER_A, "sub": "admin", "aud": AUD,
                         "iat": now, "exp": now + 300},
                        KEY_A_PUBLIC_PEM, algorithm="HS256")
    with pytest.raises(AuthError):
        verify(forged, AUD)


@pytest.mark.parametrize("header", ["jku", "jwk", "x5u"])
def test_header_supplied_key_material_rejected(header):
    token = jwt.encode({"iss": ISSUER_A, "sub": "admin"}, KEY_B_PRIVATE,
                       algorithm="RS256",
                       headers={header: "https://evil.example/jwks.json"})
    with pytest.raises(AuthError):
        verify(token, AUD)


def test_wrong_audience_rejected():
    with pytest.raises(AuthError):
        verify(mint(KEY_A_PRIVATE, iss=ISSUER_A, aud="https://other.example"), AUD)


@pytest.mark.parametrize("missing", ["exp", "iat", "aud", "sub"])
def test_missing_required_claim_rejected(missing):
    now = int(time.time())
    claims = {"iss": ISSUER_A, "sub": "u", "aud": AUD,
              "iat": now, "exp": now + 300}
    del claims[missing]
    with pytest.raises(AuthError):
        verify(jwt.encode(claims, KEY_A_PRIVATE, algorithm="RS256"), AUD)


def test_leeway_is_bounded_to_seconds():
    expired = mint(KEY_A_PRIVATE, iss=ISSUER_A, exp=int(time.time()) - 60)
    with pytest.raises(AuthError):
        verify(expired, AUD)

Identity resolution needs tests of its own, and they belong against a real database rather than a mocked repository, because the property being asserted is a schema constraint.

   def test_same_sub_under_two_issuers_are_distinct_accounts(db):
    a = resolve_account(iss=ISSUER_A, sub="admin")
    b = resolve_account(iss=ISSUER_B, sub="admin")
    assert a.id != b.id and a.tenant_id != b.tenant_id


def test_identity_pair_is_unique_at_the_schema_level(db):
    db.insert_user(issuer=ISSUER_A, subject="admin")
    with pytest.raises(UniqueViolation):
        db.insert_user(issuer=ISSUER_A, subject="admin")

The exchange endpoint gets a third group, where the negative cases concern authority rather than cryptography. An unauthenticated client is refused. An audience outside the policy returns invalid_target. A scope the subject does not hold is dropped from the result instead of granted. An actor that fails the may_act check is refused. A chain already at maximum depth is refused. The issued token’s exp is asserted to be short, so a default cannot quietly stretch it. And a successful exchange writes exactly one audit record, checked by the test rather than by hope.

Two habits make these tests worth their maintenance cost. Give each negative test a name that states the attack, so a failure in CI reads like an advisory instead of a puzzle. And mark the group so it cannot be skipped: a suite where test_issuer_b_key_cannot_sign_for_issuer_a is allowed to be xfailed during a refactor is a suite that will let this exact bug back in.

What issuer confusion looks like in logs

Detection is worth building even after the code is fixed, because the same class of bug tends to return through a new integration.

The signal to alert on is a mismatch between the issuer a token claims and the key that validated it. In correct code that combination cannot occur, so instrumenting the verifier to record both and alerting on any disagreement gives you a detector with essentially no false positives.

   def verify(token, expected_audience):
    unverified = jwt.decode(token, options={"verify_signature": False})
    issuer = unverified.get("iss")
    jwks = ISSUER_KEYS.get(issuer)
    if jwks is None:
        log.warning("auth.unknown_issuer", extra={"claimed_iss": issuer})
        raise AuthError(f"unknown issuer: {issuer!r}")
    claims = jwt.decode(token, jwks, algorithms=["RS256"],
                        audience=expected_audience, issuer=issuer)
    log.info("auth.ok", extra={"iss": issuer, "sub": claims["sub"]})
    return claims

auth.unknown_issuer firing repeatedly from one source is somebody probing your issuer handling. It is a cheap alert and a specific one.

Two other signals are worth watching. The same sub value authenticating under two different issuers within a short window suggests either a misconfiguration or an active attempt at collision. And a spike in token exchange volume from a single tenant is worth a look, since the exchange endpoint is where a compromised workflow goes to convert its access into credentials for other services.

Going from one issuer to many without an outage

Those alerts assume you already have a registry to alert against, and most systems carrying this bug do not. They started with one provider, a hardcoded key, and a verifier that was correct for the deployment it was written for. The second issuer arrived as a customer requirement, the trusted set grew a second entry, and the correctness argument quietly stopped holding. Making that transition deliberately is the part worth planning.

Start by counting the verifiers. Grep for every call into your JWT library and expect the number to be larger than you remember, because middleware, a webhook handler, an internal admin tool, and a background worker each acquired a copy at some point. Collapse them onto one function before adding anything, since two issuers multiplied by five verifiers is five chances to get it wrong.

Then build the registry while still single-issuer. It maps issuer to JWKS URI, permitted algorithms, expected audience, and tenant. With one entry it changes no behaviour, which makes it a safe deploy, and it turns the eventual second issuer into a configuration change rather than a code change.

Run it in shadow mode next. Have the verifier compute what the registry-based path would decide, log any disagreement with the current path, and keep serving the old answer. A week of that against production traffic surfaces the integrations nobody documented: the internal service minting its own tokens with a legacy issuer string, the mobile client still sending an ID token where an access token belongs, the health check carrying a credential from 2023. Fix those while the old path is still authoritative and nothing you do can lock anyone out.

Flip enforcement per audience rather than globally. Turn the registry-based verifier on for your lowest-traffic internal service first, watch the rejection counters for a day, then move up. A single flag that changes verification behaviour everywhere at once is a flag nobody will dare to flip, and a flag nobody flips is a migration that never finishes.

Only then add the second issuer, with the guardrails already in place:

  • Registration is an admin action with review, recorded alongside who approved it and when.
  • The JWKS URI is pinned at registration; discovery runs once, not on every fetch.
  • Algorithms are set per issuer, defaulting to the narrowest set the provider supports.
  • Each issuer maps to exactly one tenant in the registry, never inferred from a claim.
  • A new issuer begins with an allowlist of audiences it may be used against, widened later if there is a reason.

Keep a kill switch that disables one issuer without touching the others, and exercise it before you need it. A provider that gets compromised, or a customer whose contract ends, should be a one-line configuration change and a cache flush rather than an emergency deploy that puts every other tenant’s logins at risk.

The last thing to write down is the invariant itself, somewhere a future maintainer will actually read it, such as a docstring on the verifier: a key never verifies a token from an issuer it was not registered under. Every change to this code either preserves that sentence or breaks the system. Having it stated makes the review question obvious, and an obvious review question is the only thing that reliably survives staff turnover.

What to check in your own code

Four checks cover the majority of this class, and all of them are grep-able.

Find every place a token is verified and confirm the issuer selects the key rather than the key set being searched. Any loop over trusted keys is the pattern from the first code sample and should be rewritten. Then confirm audience validation is present, since a token minted for a different service in your ecosystem should not be accepted by this one; omitting audience is the second most common finding after issuer binding.

Check that identity resolution uses a compound key including the issuer, in the database schema and not only in application code, so a uniqueness constraint enforces it. And check that adding a new identity provider is an administrative action with review, because a platform where any tenant can register their own issuer has made every tenant a potential attacker against the verification logic above.

None of this is new cryptography or novel protocol design. It is the unglamorous work of making sure that “this signature is valid” and “this token came from who it says” are answered separately, in that order, every time. The n8n advisory is a good artefact to bring to a design review, because the fix is small and the failure is total.