Side Channel Attack in Adonisjs with Basic Auth
Side Channel Attack in Adonisjs with Basic Auth — how this specific combination creates or exposes the vulnerability
A side channel attack in AdonisJS when using HTTP Basic Auth exploits timing or behavioral differences in how the framework validates credentials. Because Basic Auth sends credentials on every request in an Authorization header, the server’s comparison logic can become an observable channel for an attacker to infer valid usernames or account state without directly compromising the password hash.
Consider a typical implementation where you look up a user by username and then compare the provided password with a stored hash. In many Node.js implementations, if the user is not found, the code may skip the hash computation and return an immediate failure. This early exit creates a measurable difference in response time compared to a case where the user exists and a hash comparison routine runs. An attacker can send many requests with guessed usernames and measure response times to identify which usernames are valid, even if the passwords are unknown. This is a classic timing side channel applied to authentication.
AdonisJS does not prescribe a specific pattern for Basic Auth, so developers often implement it manually in route middleware or an authentication provider. If that implementation uses synchronous hash comparison without constant-time guarantees and branches based on user existence, it is vulnerable. For example, using a standard equality check on a hash after retrieving the user can still leak information through subtle timing differences in the underlying string comparison, especially across different Node.js versions and underlying crypto providers. Additionally, observable behaviors such as differing HTTP status codes (401 vs 404) or response body content ("invalid password" vs "user not found") provide explicit side channels. A well-orchestrated attacker can combine timing measurements with status or body differences to enumerate valid accounts.
The framework’s pipeline and middleware behavior can amplify these risks. If your auth middleware performs early returns for missing users before invoking slower operations like database queries for roles or tokens, the network latency profile changes in a detectable way. While AdonisJS itself does not introduce these channels, the surrounding implementation choices do. Attackers may use statistical aggregation across many requests to distinguish these subtle differences, particularly in shared hosting or noisy network environments where response times vary naturally.
To summarize, the combination of Basic Auth in AdonisJS becomes a side channel vector when validation logic is not constant-time and when responses vary in timing, status, or content based on whether a username exists. Mitigations focus on ensuring that every authentication path takes the same amount of time, returns uniform responses, and avoids branching on sensitive conditions that can be measured remotely.
Basic Auth-Specific Remediation in Adonisjs — concrete code fixes
Remediation centers on making authentication paths indistinguishable to an observer. Use constant-time comparison for credentials, avoid early user-existence reveals, and standardize responses and timing. Below are concrete, realistic examples that you can adapt to your AdonisJS application.
Example 1: Constant-time validation with synthetic delays
This approach ensures that the path taken for a missing user is computationally similar to the path taken for an existing user, and it returns the same HTTP status and body shape.
import { Hash } from '@ioc:Adonis/Core/Hash'
import { DateTime } from 'luxon'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
export default class AuthController {
public async basicAuth({ request, response }: HttpContextContract) {
const header = request.header('authorization')
if (!header || !header.startsWith('Basic ')) {
return this.failAuth(response)
}
const decoded = Buffer.from(header.slice(7), 'base64').toString('utf8')
const [username, password] = decoded.split(':')
if (!username || !password) {
return this.failAuth(response)
}
// Always fetch a user record to prevent username enumeration via timing
const user = await User.findBy('username', username)
// Use a dummy hash to keep timing consistent when user is not found
const dummyHash = '$argon2id$v=19$m=65536,t=3,p=4$abcdefghijklmnop$AAAB...'
const storedHash = user ? user.password : dummyHash
// Constant-time comparison using Hash.verify which avoids early exit on mismatch
const passwordMatch = await Hash.verify(password, storedHash)
// Apply a deterministic delay when user is not found to normalize timing
if (!user) {
const start = DateTime.now().toMillis()
while (DateTime.now().toMillis() - start < 50) {
// Busy loop to consume time; adjust to meet your environment’s tolerance
}
return this.failAuth(response)
}
if (!passwordMatch) {
return this.failAuth(response)
}
// Successful authentication path
return this.successAuth(response, user)
}
private failAuth(response: HttpContextContract['response']) {
response.status(401)
response.send({ error: 'Unauthorized' })
}
private successAuth(response: HttpContextContract['response'], user: any) {
response.status(200)
response.send({ message: 'Authenticated', userId: user.id })
}
}
Example 2: Middleware that enforces uniform behavior
Encapsulate the logic in an auth middleware so route handlers remain consistent and you centralize the timing and response guarantees.
import { Exception } from '@poppinss/utils'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
export async function basicAuthMiddleware(ctx: HttpContextContract, next: () => Promise) {
const header = ctx.request.header('authorization')
let username = null
let password = null
if (header && header.startsWith('Basic ')) {
try {
const decoded = Buffer.from(header.slice(7), 'base64').toString('utf8')
const parts = decoded.split(':')
username = parts[0]
password = parts[1]
} catch (err) {
// Ignore parse errors to avoid leaking information
}
}
// Always attempt a user lookup; if not found, use a placeholder
const user = username ? await User.findBy('username', username) : null
const dummyHash = '$argon2id$v=19$m=65536,t=3,p=4$abcdefghijklmnop$AAAB...'
const storedHash = user ? user.password : dummyHash
if (!username || !password || !(await Hash.verify(password, storedHash))) {
// Ensure status and body are consistent
ctx.response.status(401).send({ error: 'Unauthorized' })
return
}
// Attach user for downstream handlers
ctx.authUser = user
await next()
}
These examples emphasize uniform timing and response shapes. In production, you should also enforce transport security (HTTPS), rotate credentials frequently, and consider whether Basic Auth is appropriate given its inherent credential-on-every-request exposure; middleBrick scans can help detect inconsistent authentication patterns that may indicate residual side channel risks in your API definitions.