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

n8n token exchange verified JWT issuer but keyed local identity on sub alone, letting a second trusted issuer's token with matching sub take over the victim's account.

- Payout: Research
- Severity: high
- Program: n8n
- Write-up by: Strix
- Disclosed: 2026-07-15
- CVE: CVE-2026-59208
- Write-up quality: 8/10
- Original: https://www.strix.ai/blog/n8n-cross-issuer-account-takeover
- Library page: https://bugbountylibrary.com/r/blog%3Astrix-ai-blog-n8n-cross-issuer-account-takeover
- Tags: area:web domain:web impact:ato impact:internal-access kind:research platform:research sev:high tech:jwt tech:oauth technique:source-review vuln:account-takeover vuln:auth-bypass

**Target:** token-exchange identity resolution service (/auth/oauth/token)
**Root cause:** TokenExchangeService.verifyToken() correctly binds verification to kid and iss, but IdentityResolutionService.resolve() looks up persisted AuthIdentity by only providerType 'token-exchange' and providerId=claims.sub, dropping issuer. Uniqueness constraint is only on providerId+providerType, so two trusted issuers emitting the same sub collapse onto one local user.
**Steps:**
1. Configure two distinct trusted issuers with different signing keys.
2. Exchange a token from issuer A with sub=42 to provision/link local user U.
3. Exchange a token from issuer B carrying the same sub=42 but different iss, email and name.
4. Observe the second exchange resolves to the same local user U and syncs issuer B's profile values onto that account.
5. Reproduce via `corepack pnpm test:integration -- --runInBand test/integration/token-exchange/cross-issuer-identity-binding.test.ts` in packages/cli.
**How found:** Auth discovery agent read token exchange verification and found it careful (kid, iss, aud, algorithm), then followed into a separate identity resolution file and noticed resolve() keyed only on providerType and claims.sub, omitting iss/kid — a gap between two functions each fine in isolation. Validation agent then reproduced with two issuers sharing a sub.
**Exploitation:** Attacker needs a valid token from any trusted issuer B whose sub matches a victim's sub under trusted issuer A. Exchanging it at /auth/oauth/token yields a local session for the victim's account, granting access to their workflows, credentials and data.
**Insight:** When reviewing federated/SSO token exchange, check that the identity key used for account lookup includes every claim used for verification (iss, kid), not just sub — verification-scoped and identity-scoped code often live in different files, and the mismatch is invisible to per-function scanners.

## Proof of concept (copied exactly from the original report)

**Integration test reproducing cross-issuer identity collision**

```
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.
```

