Skip to content

chore: Add scoped JWT strategy for public API (no-changelog)#28333

Merged
phyllis-noester merged 7 commits intomasterfrom
iam-469-scoped-jwt-strategy
Apr 13, 2026
Merged

chore: Add scoped JWT strategy for public API (no-changelog)#28333
phyllis-noester merged 7 commits intomasterfrom
iam-469-scoped-jwt-strategy

Conversation

@phyllis-noester
Copy link
Copy Markdown
Contributor

@phyllis-noester phyllis-noester commented Apr 10, 2026

PR Summary

https://linear.app/n8n/issue/IAM-469

What this PR does

Implements ScopedJwtStrategy — the auth strategy that validates scoped JWTs issued by the token exchange endpoint and authenticates public API requests with them.

Scoped JWTs can be presented in either Authorization: Bearer <token> or x-n8n-api-key headers. The strategy identifies them by their issuer (n8n-token-exchange) and is registered into AuthStrategyRegistry by TokenExchangeModule on startup, making it the second strategy after ApiKeyAuthStrategy.

Changes

ScopedJwtStrategy (new)

  • Extracts tokens from both Authorization: Bearer and x-n8n-api-key headers
  • Performs a cheap decode() + issuer check before the expensive verify() call, so non-token-exchange JWTs pass through as null without signature overhead
  • Resolves subsubject and optionally act.subactor from the DB
  • Loads scopes from the acting user's role (actor ?? subject) — not from the JWT payload — so permission changes take effect immediately without re-issuing tokens
  • Sets req.user to the acting principal (actor if delegation is present, subject otherwise)
  • Sets req.tokenGrant = { scopes, subject, actor? }

ApiKeyAuthStrategy (updated)

  • Added subject: apiKeyRecord.user to req.tokenGrantTokenGrant.subject is now required
  • Added early-exit before the DB lookup: if the value in x-n8n-api-key is a JWT with an issuer other than API_KEY_ISSUER, the strategy returns null (abstain) instead of false (fail-fast). Without this, a token-exchange JWT in x-n8n-api-key would short-circuit the auth chain before ScopedJwtStrategy runs

TokenExchangeModule (updated)

  • Registers ScopedJwtStrategy into AuthStrategyRegistry at the end of init(), behind the existing feature flag guard

Key decisions

Scopes from role, not JWT payload. Scopes are resolved at request time from the acting user's role.scopes rather than from the scope claim embedded in the JWT. This means permission changes take effect immediately and the JWT itself only needs to carry identity claims (sub, act).

Actor is optional. If the JWT carries an act claim but the actor user ID is not found in the DB (e.g. the account was deleted after the token was issued), authentication continues with the subject acting as principal. Only if the actor is found and disabled does authentication fail. This keeps token-exchange tokens usable across user lifecycle events.

ApiKeyAuthStrategy abstains on non-API-key JWTs. The issuer check (decoded.iss !== API_KEY_ISSUER) is placed before the DB lookup so a token-exchange JWT in x-n8n-api-key gets null (pass through) rather than false (block). A null decode (non-JWT string) still returns false to preserve existing rejection behaviour for garbage values.

Module self-registration (Option B). ScopedJwtStrategy is registered by TokenExchangeModule.init() rather than in the public API bootstrap. This keeps the token-exchange module self-contained and ensures the strategy is only active when the module is enabled.

Review / Merge checklist

  • I have seen this code, I have run this code, and I take responsibility for this code.
  • PR title and summary are descriptive. (conventions)
  • Docs updated or follow-up ticket created.
  • Tests included.
  • PR Labeled with Backport to Beta, Backport to Stable, or Backport to v1 (if the PR is an urgent fix that needs to be backported)

@n8n-assistant n8n-assistant Bot added core Enhancement outside /nodes-base and /editor-ui n8n team Authored by the n8n team labels Apr 10, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 10, 2026

Codecov Report

❌ Patch coverage is 94.11765% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...les/token-exchange/services/scoped-jwt.strategy.ts 95.55% 1 Missing and 1 partial ⚠️
packages/cli/src/public-api/index.ts 75.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 10, 2026

Performance Comparison

Comparing currentlatest master14-day baseline

Idle baseline with Instance AI module loaded

Metric Current Latest Master Baseline (avg) vs Master vs Baseline Status
instance-ai-heap-used-baseline 186.38 MB 186.02 MB 186.31 MB (σ 0.25) +0.2% +0.0%
instance-ai-rss-baseline 340.14 MB 380.71 MB 369.52 MB (σ 24.20) -10.7% -7.9% ⚠️

docker-stats

Metric Current Latest Master Baseline (avg) vs Master vs Baseline Status
docker-image-size-runners 393.00 MB 418.00 MB 391.43 MB (σ 11.93) -6.0% +0.4%
docker-image-size-n8n 1269.76 MB 1269.76 MB 1269.76 MB (σ 0.00) +0.0% +0.0%

Memory consumption baseline with starter plan resources

Metric Current Latest Master Baseline (avg) vs Master vs Baseline Status
memory-heap-used-baseline 114.15 MB 114.20 MB 113.67 MB (σ 1.06) -0.0% +0.4%
memory-rss-baseline 285.31 MB 352.66 MB 283.64 MB (σ 42.70) -19.1% +0.6%
How to read this table
  • Current: This PR's value (or latest master if PR perf tests haven't run)
  • Latest Master: Most recent nightly master measurement
  • Baseline: Rolling 14-day average from master
  • vs Master: PR impact (current vs latest master)
  • vs Baseline: Drift from baseline (current vs rolling avg)
  • Status: ✅ within 1σ | ⚠️ 1-2σ | 🔴 >2σ regression

@phyllis-noester phyllis-noester requested review from a team, BGZStephen, afitzek, cstuncsik and guillaumejacquart and removed request for a team April 10, 2026 15:58
Copy link
Copy Markdown
Contributor

@afitzek afitzek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code looks good, there are just 2 test problems that we need to fix.

type: apiKey
in: header
name: X-N8N-API-KEY
BearerAuth:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be confusing for users that use an api key, because the api key is not accepted in this format.

});

it('returns false when subject user does not exist in DB', async () => {
const token = makeScopedJwt('non-existent-id');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user id should be a non existing uuid.


it('continues without actor and uses subject scopes when actor ID is not in DB', async () => {
const subject = await createOwner();
const token = makeScopedJwt(subject.id, 'non-existent-actor');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The actor id should be a non existing uuid.

@phyllis-noester phyllis-noester marked this pull request as ready for review April 13, 2026 07:31
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 13 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/cli/src/public-api/index.ts">

<violation number="1" location="packages/cli/src/public-api/index.ts:137">
P1: `ApiKeyAuth` should not delegate to the full auth registry; it now lets scoped Bearer tokens satisfy operations declared as API-key-only. According to linked Linear issue IAM-469, existing API key auth should remain unaffected.</violation>

<violation number="2" location="packages/cli/src/public-api/index.ts:138">
P0: Custom agent: **Security Review**

According to linked Linear issue IAM-469, bearer-authenticated scoped JWTs must carry resource constraints into `tokenGrant`. Enabling `BearerAuth` here without propagating or enforcing `resource` turns resource-scoped tokens into effectively account-wide scope tokens on the public API.</violation>
</file>

<file name="packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts">

<violation number="1" location="packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts:63">
P0: Custom agent: **Security Review**

According to linked Linear issue IAM-469, this must preserve the exchanged JWT's scope/resource when building `req.tokenGrant`. Mapping `tokenGrant.scopes` from `actingUser.role.scopes` and dropping `payload.resource` turns a scoped JWT into a full-role token, so a token exchanged for `workflow:read` can call any public API endpoint allowed by the actor's role.</violation>
</file>

<file name="packages/@n8n/db/src/entities/types-db.ts">

<violation number="1" location="packages/@n8n/db/src/entities/types-db.ts:424">
P1: TokenGrant drops the `roles`/`resource` fields required for scoped JWTs. According to linked Linear issue IAM-469, those claims still need to be carried on `req.tokenGrant`.</violation>
</file>

<file name="packages/cli/src/modules/token-exchange/token-exchange.module.ts">

<violation number="1" location="packages/cli/src/modules/token-exchange/token-exchange.module.ts:43">
P1: According to linked Linear issue IAM-469, this registers `ScopedJwtStrategy` too early: because API key auth is added later during public API setup, the scoped strategy becomes first in the chain and can reject requests before a valid API key is tried.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client
    participant API as Public API (Express)
    participant Registry as AuthStrategyRegistry
    participant KeyAuth as ApiKeyAuthStrategy
    participant ScopedAuth as ScopedJwtStrategy
    participant JWT as JwtService
    participant DB as UserRepository

    Note over Client,ScopedAuth: Authentication Flow for Scoped JWTs

    Client->>API: Request (Header: Authorization or x-n8n-api-key)
    API->>Registry: authenticate(req)

    Registry->>KeyAuth: authenticate(req)
    KeyAuth->>JWT: decode(token)
    alt CHANGED: Token is JWT but Issuer != API_KEY_ISSUER
        KeyAuth-->>Registry: return null (Abstain)
    else Invalid JWT
        KeyAuth-->>Registry: return false (Fail)
    end

    Registry->>ScopedAuth: NEW: authenticate(req)
    ScopedAuth->>ScopedAuth: extractToken() from headers
    ScopedAuth->>JWT: decode(token)

    alt NEW: Issuer == 'n8n-token-exchange'
        ScopedAuth->>JWT: verify(token) signature & expiry
        
        ScopedAuth->>DB: findUser(sub)
        DB-->>ScopedAuth: subject user + roles
        
        opt JWT has 'act' claim (Delegation)
            ScopedAuth->>DB: findUser(act.sub)
            DB-->>ScopedAuth: actor user + roles
        end

        ScopedAuth->>ScopedAuth: Map scopes from DB Role (not JWT)
        ScopedAuth->>API: Set req.user & req.tokenGrant
        ScopedAuth-->>Registry: return true
    else Not a Scoped JWT
        ScopedAuth-->>Registry: return null
    end

    alt Authentication Success
        Registry-->>API: success
        API->>API: NEW: Update LastActiveAt & Emit Event
        API-->>Client: 200 OK + Data
    else Authentication Failed
        Registry-->>API: failure
        API-->>Client: 401 Unauthorized
    end
Loading

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

return authenticated;
},
ApiKeyAuth: authenticate,
BearerAuth: authenticate,
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Custom agent: Security Review

According to linked Linear issue IAM-469, bearer-authenticated scoped JWTs must carry resource constraints into tokenGrant. Enabling BearerAuth here without propagating or enforcing resource turns resource-scoped tokens into effectively account-wide scope tokens on the public API.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/public-api/index.ts, line 138:

<comment>According to linked Linear issue IAM-469, bearer-authenticated scoped JWTs must carry resource constraints into `tokenGrant`. Enabling `BearerAuth` here without propagating or enforcing `resource` turns resource-scoped tokens into effectively account-wide scope tokens on the public API.</comment>

<file context>
@@ -110,28 +134,8 @@ function createLazyValidatorMiddleware(
-									return authenticated;
-								},
+								ApiKeyAuth: authenticate,
+								BearerAuth: authenticate,
 							},
 						},
</file context>
Fix with Cubic


// 7. Scopes come from the acting user's role (role.scopes is eager: true)
req.tokenGrant = {
scopes: actingUser.role.scopes.map((s) => s.slug),
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Custom agent: Security Review

According to linked Linear issue IAM-469, this must preserve the exchanged JWT's scope/resource when building req.tokenGrant. Mapping tokenGrant.scopes from actingUser.role.scopes and dropping payload.resource turns a scoped JWT into a full-role token, so a token exchanged for workflow:read can call any public API endpoint allowed by the actor's role.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts, line 63:

<comment>According to linked Linear issue IAM-469, this must preserve the exchanged JWT's scope/resource when building `req.tokenGrant`. Mapping `tokenGrant.scopes` from `actingUser.role.scopes` and dropping `payload.resource` turns a scoped JWT into a full-role token, so a token exchanged for `workflow:read` can call any public API endpoint allowed by the actor's role.</comment>

<file context>
@@ -0,0 +1,94 @@
+
+		// 7. Scopes come from the acting user's role (role.scopes is eager: true)
+		req.tokenGrant = {
+			scopes: actingUser.role.scopes.map((s) => s.slug),
+			subject,
+			...(actor && { actor }),
</file context>
Fix with Cubic

scopes: string[];
actor?: { userId: string };
actor?: User;
subject: User;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: TokenGrant drops the roles/resource fields required for scoped JWTs. According to linked Linear issue IAM-469, those claims still need to be carried on req.tokenGrant.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/@n8n/db/src/entities/types-db.ts, line 424:

<comment>TokenGrant drops the `roles`/`resource` fields required for scoped JWTs. According to linked Linear issue IAM-469, those claims still need to be carried on `req.tokenGrant`.</comment>

<file context>
@@ -419,9 +419,9 @@ export type AuthenticationInformation = {
 	scopes: string[];
-	actor?: { userId: string };
+	actor?: User;
+	subject: User;
 }
 
</file context>
Fix with Cubic


return authenticated;
},
ApiKeyAuth: authenticate,
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: ApiKeyAuth should not delegate to the full auth registry; it now lets scoped Bearer tokens satisfy operations declared as API-key-only. According to linked Linear issue IAM-469, existing API key auth should remain unaffected.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/public-api/index.ts, line 137:

<comment>`ApiKeyAuth` should not delegate to the full auth registry; it now lets scoped Bearer tokens satisfy operations declared as API-key-only. According to linked Linear issue IAM-469, existing API key auth should remain unaffected.</comment>

<file context>
@@ -110,28 +134,8 @@ function createLazyValidatorMiddleware(
-
-									return authenticated;
-								},
+								ApiKeyAuth: authenticate,
+								BearerAuth: authenticate,
 							},
</file context>
Fix with Cubic

// ScopedJwtStrategy runs after ApiKeyAuthStrategy (which abstains for token-exchange JWTs).
const { ScopedJwtStrategy } = await import('./services/scoped-jwt.strategy');
const { AuthStrategyRegistry } = await import('@/services/auth-strategy.registry');
Container.get(AuthStrategyRegistry).register(Container.get(ScopedJwtStrategy));
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: According to linked Linear issue IAM-469, this registers ScopedJwtStrategy too early: because API key auth is added later during public API setup, the scoped strategy becomes first in the chain and can reject requests before a valid API key is tried.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/modules/token-exchange/token-exchange.module.ts, line 43:

<comment>According to linked Linear issue IAM-469, this registers `ScopedJwtStrategy` too early: because API key auth is added later during public API setup, the scoped strategy becomes first in the chain and can reject requests before a valid API key is tried.</comment>

<file context>
@@ -35,5 +35,11 @@ export class TokenExchangeModule implements ModuleInterface {
+		// ScopedJwtStrategy runs after ApiKeyAuthStrategy (which abstains for token-exchange JWTs).
+		const { ScopedJwtStrategy } = await import('./services/scoped-jwt.strategy');
+		const { AuthStrategyRegistry } = await import('@/services/auth-strategy.registry');
+		Container.get(AuthStrategyRegistry).register(Container.get(ScopedJwtStrategy));
 	}
 }
</file context>
Fix with Cubic

Copy link
Copy Markdown
Contributor

@afitzek afitzek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀

@phyllis-noester phyllis-noester added this pull request to the merge queue Apr 13, 2026
Merged via the queue into master with commit 06a666a Apr 13, 2026
55 of 56 checks passed
@phyllis-noester phyllis-noester deleted the iam-469-scoped-jwt-strategy branch April 13, 2026 11:08
@n8n-assistant
Copy link
Copy Markdown
Contributor

n8n-assistant Bot commented Apr 14, 2026

Got released with n8n@

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Enhancement outside /nodes-base and /editor-ui n8n team Authored by the n8n team Released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants