Token replay protection added to exchange flow
A database-backed JTI store prevents token replay attacks in n8n's token exchange flow, with atomic consumption and configurable batch cleanup.
A skeleton service that always returned true has been replaced with a production-ready JTI (JWT ID) store backed by a database table. The new implementation prevents token replay attacks by atomically recording each consumed JTI — if a token has already been exchanged, attempts to reuse it are rejected.
The atomic consume uses database-level operations to ensure safety even under concurrent requests. PostgreSQL deployments use a CTE with INSERT...ON CONFLICT DO NOTHING, while SQLite uses INSERT OR IGNORE with a changes() check. Either way, only one request wins when two simultaneous attempts are made to exchange the same token.
Expired JTIs accumulate over time, so a background cleanup service runs on a configurable interval (default: 60 seconds), deleting expired rows in bounded batches to avoid overwhelming the database. The service respects leadership — only the leader instance runs cleanup — and shuts down gracefully when the instance stops.
Login endpoints on the embed auth controller also received IP-based rate limiting at 20 requests per minute, adding a layer of brute-force protection.
The work is part of a broader initiative to secure n8n's token exchange flow, with this PR delivering the core replay protection mechanism.
View Original GitHub Description
Summary
Replaces the skeleton JtiStoreService with a database-backed implementation that prevents token replay attacks in the token exchange flow.
Key changes:
TokenExchangeJtientity + migration — Newtoken_exchange_jtitable with columnsjti(PK),expiresAt,createdAt. Supports both PostgreSQL and SQLite.TokenExchangeJtiRepository— AtomicatomicConsume(jti, expiresAt)method usingINSERT ... ON CONFLICT DO NOTHING(Postgres CTE) /INSERT OR IGNORE+changes()(SQLite transaction). Returnstrueon first use,falseon replay. Also providesdeleteExpiredBatch(batchSize)for bounded cleanup.JtiStoreService— Delegates to the repository with a 60-second grace period on expiry to account for clock skew.JtiCleanupService— Periodic background job that deletes expired JTI rows in configurable batches. Loops until fewer thanbatchSizerows are deleted per iteration. Graceful shutdown via@OnShutdown().EmbedAuthController— Added IP-based rate limiting (20 req/min) to GET and POST login endpoints.- Config — Two new env vars:
N8N_TOKEN_EXCHANGE_JTI_CLEANUP_INTERVAL_SECONDS(default: 60) andN8N_TOKEN_EXCHANGE_JTI_CLEANUP_BATCH_SIZE(default: 1000).
Test coverage:
- Unit tests for
JtiStoreService,JtiCleanupService, andTokenExchangeJtiRepository(mock-based, both Postgres and SQLite paths) - Integration test (
token-exchange-jti.repository.integration.test.ts) validatingatomicConsumeanddeleteExpiredBatchagainst a real database
Related Linear tickets, Github issues, and Community forum posts
https://linear.app/n8n/issue/IAM-461
Review / Merge checklist
- 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, orBackport to v1(if the PR is an urgent fix that needs to be backported)