July 15th, 2026

Same Subject, Wrong User: A Cross-Issuer Account Takeover in n8n

Months ago, we ran Strix against n8n and it came back with an authentication bug in the token-exchange flow. We submitted the finding and forgot about it. Then, a few weeks ago, we learned it had been assigned CVE-2026-59208 (High). Short version: n8n trusts who signed a token when it verifies it — but forgets who signed it when it decides whose account you are.

This is a small post about a small diff with a large blast radius.

What is n8n?

n8n is a workflow automation platform, deployable in n8n's cloud or on your own infrastructure. The project has nearly 200,000 GitHub stars, more than 100 million Docker pulls, and 500-plus integrations. For many teams, n8n sits in the middle of the machinery that moves data and credentials around — which makes its authentication boundary a consequential place to get identity wrong.

The bug in one paragraph

n8n's token exchange lets an external identity provider mint a JWT that n8n swaps for a local session. When multiple trusted issuers are configured, n8n verifies each incoming token against the correct issuer — key ID, iss, audience, algorithm, all checked properly. But when it maps that verified token to a local account, it keys on the JWT sub claim alone. Issuer is dropped. So two different trusted issuers that happen to emit the same subject value resolve to the same local user.

If you can get a valid token from trusted issuer B with a sub that matches a victim registered under trusted issuer A, you log in as the victim. This results in a cross-issuer account takeover — their workflows, their credentials, their data.

Vulnerable flowCVE-2026-59208

Two trusted issuers emit the same sub. Both tokens verify, then identity resolution drops iss and collapses them onto one local account.

Trusted issuer A
ississuer-a
sub42
Trusted issuer B
ississuer-b
sub42
JWT verifies
key · iss · aud
JWT verifies
key · iss · aud
Identity lookupiss discarded
providerTypetoken-exchangeproviderId42
Local user U
same account
Fixed flowQualify the id with the issuer: sha256(iss)::sub
issuer-a + sub:42
sha256(issuer-a)::42
Identity A
issuer-b + sub:42
sha256(issuer-b)::42
Identity B
No collision: the issuer stays part of the identity key.

Both tokens are valid. The bug is what happens next: n8n throws away iss, keeps sub, and collapses two issuers onto one local identity.

How Strix found it

The cards below are rendered exactly the way Strix shows a live run: the agent's chain of thought, the subagent it spawned, and the terminal it drove, lightly trimmed for length.

The run spun up 32 agents. The auth discovery agent walked the packages/cli auth modules and, at first, gave token exchange a clean-ish read:

Auth Discovery Agent
Thinking

Token exchange service appears relatively careful: verifies kid, iss, audience, algorithm list from trusted key store.

Then it kept pulling the thread into the identity resolution step — a different file from the verification step — and the story changed:

Auth Discovery Agent
Thinking

TokenExchangeService.verifyToken() correctly binds verification to both kid and iss [...] But IdentityResolutionService.resolve() appears to bind external identities only by providerType: 'token-exchange' and providerId: claims.sub. It does not include issuer (iss) or key (kid) in the persisted identity lookup [...] a valid token from issuer B using the same sub as a user [under issuer A]...

That's the entire vulnerability, spotted as a gap between two functions: verification is issuer-scoped, identity binding is not. As we are all learning, this is the kind of thing static scanners miss — each function looks fine in isolation.

We of course don't want to add to n8n's backlog of false positive reports, so a validation agent set out to prove it against the real public endpoint:

Token Exchange Identity Validation Agent
Thinking

Strongest proof would exercise a public endpoint (/auth/oauth/token) with two issuers having [the] same sub, then show [the] same local user ID is resolved.

Token Exchange Identity Validation Agent
subagentToken Exchange Identity Validation Agent

Validate the suspected cross-issuer token-exchange identity binding weakness in the n8n monorepo.

Read wiki:overview and wiki:security first. A strong source-backed candidate exists already:

  • packages/cli/src/modules/token-exchange/services/identity-resolution.service.ts
    • identity lookup uses only { providerId: claims.sub, providerType: 'token-exchange' }
    • tokenContext.issuer is logged/emitted but does not participate in identity keying
  • packages/@n8n/db/src/entities/auth-identity.ts
    • uniqueness is only on providerId + providerType
  • packages/cli/test/integration/token-exchange/cross-issuer-identity-binding.test.ts
    • appears to intentionally demonstrate that two trusted issuers with the same external sub resolve to the same local account

Your job:

  • dynamically reproduce this behavior locally using the existing token-exchange integration harness if possible
  • confirm exact prerequisites:
    • token exchange enabled
    • two distinct trusted issuers/keys
    • same external sub, different iss
  • verify whether issuer B can obtain access to the local account originally provisioned/linked by issuer A
  • assess practical impact:

The validation agent got a narrow job: reproduce the collision with two trusted issuers and the same sub.

It configured two trusted issuers with different signing keys, exchanged a token from issuer A (provisioning user U), then exchanged a token from issuer B carrying the same sub but a different email and name — and watched the second exchange resolve to the same local user U, with issuer B's profile values synced onto that account. Bang, verified.

Token Exchange Identity Validation Agent
Terminal
cd /workspace/n8n/packages/cli
corepack pnpm test:integration -- --runInBand test/integration/token-exchange/cross-issuer-identity-binding.test.ts
> n8n@2.22.0 test:integration /workspace/n8n/packages/cli
> N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.integration.js -- --runInBand test/integration/token-exchange/cross-issuer-identity-binding.test.ts

Determining test suites to run...ts-jest[versions] (WARN) Version 6.0.2 of typescript installed has not been tested with ts-jest. If you're experiencing issues, consider using a supported version (...
ts-jest[versions] (WARN) Version 6.0.2 of typescript installed has not been tested with ts-jest. If you're experiencing issues, consider using a supported version (>=4.3.0 <6.0.0-0). Please do not ...
[baseline-browser-mapping] The data in this module is over two months old.  To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D`
 PASS  test/integration/token-exchange/cross-issuer-identity-binding.test.ts (5.268 s)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        5.661 s
Ran all test suites matching /--runInBand|test\/integration\/token-exchange\/cross-issuer-identity-binding.test.ts/i.
[baseline-browser-mapping] The data in this module is over two months old.  To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D`

The test passed. In this case, PASS meant issuer B reached the account provisioned by issuer A.

The fix

n8n's fix does exactly what the finding pointed at — it stops keying identities on the bare sub and qualifies it with the issuer. The provider id becomes sha256(issuer)::sub:

1export function qualifiedProviderId(issuer: string, sub: string): string {
2 return `${createHash('sha256').update(issuer).digest('hex')}::${sub}`;
3}

Every lookup and every persisted AuthIdentity — the known-identity path, the email-link path, and JIT provisioning — now uses qualifiedProviderId(claims.iss, claims.sub) instead of claims.sub.

Fixed in n8n 2.28.1 and 2.27.4. If you run token exchange with more than one trusted issuer, upgrade. If you can't yet, drop to a single trusted issuer or disable token exchange until you can.

Found autonomously, validated dynamically, reported privately, fixed fast. That's the loop we're building.