Race Condition in Adonisjs with Jwt Tokens
Race Condition in Adonisjs with Jwt Tokens — how this specific combination creates or exposes the vulnerability
A race condition in AdonisJS when using JWT tokens typically occurs around token invalidation or revocation checks during concurrent requests. Because JWTs are often validated statelessly (signature and claims), developers sometimes add a small cache or database check (e.g., token denylist or user status) between verification and authorization. If two requests arrive at nearly the same time before the cache/database is updated, both may pass validation, enabling a privilege escalation or unauthorized action (BOLA/IDOR-like outcome).
Consider an endpoint that changes a user’s email. The flow is: verify JWT, check if the user intends to revoke old sessions (e.g., via a denylist table), then update the email. An attacker who triggers a revoke (e.g., logs out from all devices) and immediately sends a crafted request can race the denylist write. Because AdonisJS may handle these requests on different event loop ticks or worker threads, both requests might read an empty denylist, causing the second request to apply despite the logout intent. This is not a flaw in JWT itself but in how stateful checks are sequenced around an otherwise stateless token.
In AdonisJS, this often maps to the Authorization and BOLA/IDOR checks among the 12 parallel security scans. The scanner tests whether authorization logic is subject to timing gaps by simulating concurrent calls with different token states. If the application relies on per-request database or cache checks without atomic safeguards, the scan may surface a high-severity finding tied to authorization integrity.
Real-world patterns that can trigger this include: using a denylist table for token blacklisting without row-level locking, checking user status in a pre-handler hook that runs after JWT verification, or performing sensitive mutations after asynchronous guards that do not lock the resource. These patterns are especially risky when tokens carry broad scopes or when endpoints mutate state based on claims that can be stale between checks.
Jwt Tokens-Specific Remediation in Adonisjs — concrete code fixes
To mitigate race conditions with JWT tokens in AdonisJS, favor stateless verification where possible and ensure that any stateful checks are atomic and ordered. Below are concrete patterns and code examples.
1. Prefer stateless verification and short-lived tokens
Keep token validation purely cryptographic. Avoid post-verification database lookups unless absolutely necessary. Use short expirations and rotate secrets regularly.
import { verify } from 'jsonwebtoken'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
export default class AuthMiddleware {
public async handle(ctx: HttpContextContract) {
const token = ctx.request.header('authorization')?.replace('Bearer ', '')
if (!token) {
return ctx.response.unauthorized('Missing token')
}
try {
const payload = verify(token, process.env.JWT_SECRET!, { algorithms: ['HS256'] })
ctx.auth.user = payload as any
} catch (error) {
return ctx.response.unauthorized('Invalid token')
}
}
}
2. Atomic denylist checks with database transactions or locking
If you must maintain a denylist (e.g., for logout), perform the check within a transaction or use a lock to serialize concurrent reads/writes. In AdonisJS with Lucid, you can use DB.transaction and SELECT … FOR UPDATE where supported.
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Database from '@ioc:Adonis/Lucid/Database'
import { DateTime } from 'luxon'
export default class TokenDenylistCheck {
public static async handle(ctx: HttpContextContract, next: () => Promise) {
const token = ctx.request.header('authorization')?.replace('Bearer ', '')
if (!token) {
return ctx.response.unauthorized('Missing token')
}
// Assume payload contains jti and exp
const { jti, exp } = verify(token, process.env.JWT_SECRET!) as any
const now = Math.floor(Date.now() / 1000)
if (exp < now) {
return ctx.response.unauthorized('Token expired')
}
// Atomic check using transaction with lock
await Database.transaction(async (trx) => {
const entry = await trx
.from('token_denylist')
.where('jti', jti)
.lockForUpdate()
.first()
if (entry) {
throw new Error('Token revoked')
}
})
await next()
}
}
3. Idempotent mutations and versioning
Make state-changing endpoints idempotent using request IDs or versioned resources. This reduces the impact of a successful race by ensuring repeated requests do not change outcome unexpectedly.
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import RequestId from 'uuid-by-string'
export default class UpdateEmail {
public static async handle(ctx: HttpContextContract) {
const { newEmail } = ctx.request.body()
const requestId = ctx.request.id() // or a header like X-Request-Id
const user = ctx.auth.user
// Use requestId to deduplicate or order operations safely
await user.related('emails').query().where({ requestId }).delete()
await user.related('emails').create({ email: newEmail, requestId })
ctx.response.ok({ updated: true })
}
}
4. Server-side session invalidation instead of token revocation
For sensitive operations (e.g., email change), require re-authentication or use server-side sessions to invalidate prior sessions, avoiding reliance on token denylists subject to races.
// Example: Require recent re-auth for email change
export default class RequireReauth {
public static async handle(ctx: HttpContextContract, next: () => Promise) {
const reauthAt = ctx.session.get('reauthAt')
const now = Date.now()
if (!reauthAt || now - reauthAt > 15 * 60 * 1000) {
return ctx.response.unauthorized('Reauthentication required')
}
await next()
}
}