Scoped JWT token permission context arrives
API key authentication now reads permissions once at auth time, eliminating redundant database queries on every endpoint call. A new TokenGrant interface on AuthenticatedRequest prepares the codebase for future scoped JWT support.
API key authentication in n8n's public API was making a database round-trip on every endpoint that required a permission check. The auth strategy loaded the API key record, then each scoped endpoint fetched it again to verify permissions — unnecessary overhead that added latency to every protected call.
The fix restructures where permission data lives in the request lifecycle. A new TokenGrant interface has been added to AuthenticatedRequest that carries resolved scopes from the API key. During authentication, the ApiKeyAuthStrategy extracts these scopes and stores them on the request object. Subsequent middleware simply reads from req.tokenGrant.scopes rather than hitting the database again.
This approach also lays groundwork for future scoped JWT tokens. The TokenGrant interface mirrors what an OAuth 2.0 token exchange would issue: a roles array for audit logging, concrete scopes resolved from those roles, and an optional actor field for delegation. The resource constraint field was deliberately omitted — that's a separate concern for a future slice.
For now, session-based routes and non-API-key authentication paths are unaffected. The tokenGrant field is optional on AuthenticatedRequest, so existing code continues working unchanged.
View Original GitHub Description
IAM-467: TokenGrant type and scope enforcement changes
What was done
1. Added the TokenGrant interface (packages/@n8n/db/src/entities/types-db.ts)
A new interface attached to AuthenticatedRequest that carries the permission context from a scoped JWT:
interface TokenGrant {
roles?: string[]; // role URNs — for audit logging only
scopes: string[]; // concrete scopes resolved from roles
actor?: { userId: string }; // delegation context
}
roles is optional on TokenGrant. The resource field from the ticket spec was
deliberately omitted — resource constraint enforcement is out of scope for this slice.
2. Rewired ApiKeyAuthStrategy to populate req.tokenGrant
The strategy now queries ApiKeyRepository directly (instead of UserRepository via
relation join), and after successful auth sets:
req.tokenGrant = { scopes: apiKeyRecord.scopes ?? [] };
Scopes are resolved once at auth time and stored on the request, rather than re-checking the raw API key in the scope middleware.
3. Replaced apiKeyHasScope with publicApiScope in global.middleware.ts
The old getApiKeyScopeMiddleware re-fetched the API key from the DB on every scope
check — a redundant DB hit after auth. The new makeScopeEnforcementMiddleware simply
reads req.tokenGrant.scopes. If no tokenGrant is present, it returns 403 immediately.
4. Removed getApiKeyScopeMiddleware from PublicApiKeyService — dead code once
scope enforcement moved to reading from the request.
Design decisions
| Decision | Rationale |
|---|---|
| Scopes resolved once at auth time | Avoids a second DB round-trip per endpoint. The auth strategy is the single place where the API key record is loaded, so that's where scopes should be extracted. |
roles preserved but not enforced | Roles are available for downstream audit logging per the spec, but enforcement uses only the already-resolved scopes array — avoids re-doing role→scope resolution on every request. |
resource field omitted | Resource constraint enforcement is a separate concern, not part of this slice. The interface is open to adding it later. |
403 when tokenGrant absent | Treats absence of a token grant as an authorisation failure, not an authentication failure. If future non-API-key auth paths need to flow through publicApiScope, this will need revisiting. |
| No changes to session/RBAC auth paths | tokenGrant is optional on AuthenticatedRequest; existing session-based routes that don't go through publicApiScope are entirely unaffected. |
Query through ApiKeyRepository directly | The old strategy joined through UserRepository to reach the API key. Reversing the join (ApiKey → User) gives direct access to apiKeyRecord.scopes without an extra query. |
Related Linear tickets, Github issues, and Community forum posts
[<!-- Include links to **Linear ticket** or Github issue or Community forum post. Important in order to close *automatically* and provide context to reviewers. https://linear.app/n8n/issue/ -->
<!-- Use "closes #<issue-number>", "fixes #<issue-number>", or "resolves #<issue-number>" to automatically close issues when the PR is merged. -->](https://linear.app/n8n/issue/IAM-467/2b3-tokengrant-type-and-scope-enforcement-changes)
Manual API testing
Three curl calls against http://localhost:5678 to verify the full auth path end-to-end.
Replace <API_KEY> with a valid scoped JWT API key.
1. GET /api/v1/workflows — scope gate + role-based result filtering
Exercises publicApiScope('workflow:list') (reads req.tokenGrant.scopes) and the role branch in the handler (req.user.role.slug at workflows.handler.ts:158). If the role relation was not loaded by the new ApiKeyRepository join, this call would 500.
curl -s -X GET 'http://localhost:5678/api/v1/workflows' \
-H 'X-N8N-API-KEY: <API_KEY>' \
| jq '{count: .count, firstWorkflow: .data[0].name}'
Expected: { "count": N, "firstWorkflow": "..." }
2. POST /api/v1/users — user creation through apiKeyHasScopeWithGlobalScopeFallback
Exercises the scope path that now also reads from req.tokenGrant rather than re-fetching the API key from the DB.
curl -s -X POST 'http://localhost:5678/api/v1/users' \
-H 'X-N8N-API-KEY: <API_KEY>' \
-H 'Content-Type: application/json' \
-d '[{"email": "test-api-user@example.com", "role": "global:member"}]' \
| jq '.'
Expected: array with the created user, or { "message": "User already exists" } on re-run.
3. GET /api/v1/credentials — direct req.user.role.slug check in handler body
At credentials.handler.ts:185 the handler reads req.user.role.slug to decide whether to return all credentials or only the caller's own. This is the most targeted test that relations: { user: { role: true } } in the new ApiKeyRepository query actually populates the role.
curl -s -X GET 'http://localhost:5678/api/v1/credentials' \
-H 'X-N8N-API-KEY: <API_KEY>' \
| jq '{count: .count, role_check: "passed - role was loaded"}'
Expected: { "count": N, "role_check": "passed - role was loaded" } — a 500 here means req.user.role was null.
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) <!-- **Remember, the title automatically goes into the changelog. Use `(no-changelog)` otherwise.** -->
- Docs updated or follow-up ticket created.
- Tests included. <!-- A bug is not considered fixed, unless a test is added to prevent it from happening again. A feature is not complete without tests. -->
- PR Labeled with
Backport to Beta,Backport to Stable, orBackport to v1(if the PR is an urgent fix that needs to be backported)