Merged
Size
L
Change Breakdown
Security60%
Feature25%
Config10%
Testing5%
#27944feat(core): Add JTI store with atomic consume and cleanup job for token exchange

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:

  • TokenExchangeJti entity + migration — New token_exchange_jti table with columns jti (PK), expiresAt, createdAt. Supports both PostgreSQL and SQLite.
  • TokenExchangeJtiRepository — Atomic atomicConsume(jti, expiresAt) method using INSERT ... ON CONFLICT DO NOTHING (Postgres CTE) / INSERT OR IGNORE + changes() (SQLite transaction). Returns true on first use, false on replay. Also provides deleteExpiredBatch(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 than batchSize rows 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) and N8N_TOKEN_EXCHANGE_JTI_CLEANUP_BATCH_SIZE (default: 1000).

Test coverage:

  • Unit tests for JtiStoreService, JtiCleanupService, and TokenExchangeJtiRepository (mock-based, both Postgres and SQLite paths)
  • Integration test (token-exchange-jti.repository.integration.test.ts) validating atomicConsume and deleteExpiredBatch against 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, or Backport to v1 (if the PR is an urgent fix that needs to be backported)
© 2026 · via Gitpulse