Timing Attacks Explained — and How AdonisJS Protects You

Timing Attacks Explained — and How AdonisJS Protects You

Chimezie Enyinnaya
Chimezie Enyinnaya March 30, 2026

When you write a login implementation, you're probably thinking about the obvious threats: SQL injection, weak passwords, missing rate limiting. What you’re most likely not thinking about is how long your server takes to respond.

That's precisely what a timing attack exploits.

This article walks through what timing attacks are, why the login code you've written is likely vulnerable, and how AdonisJS's AuthFinder mixin closes the gap without making you think about any of it.

The login code that looks fine

Here's a login controller that any competent developer might write. It's clean, it's readable, it uses the hash service correctly. Take a look:

import User from '#models/user'
import hash from '@adonisjs/core/services/hash'
import type { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
  async store({ request, response }: HttpContext) {
    const { email, password } = request.only(['email', 'password'])

    const user = await User.findBy('email', email)
    if (!user) {
      return response.abort('Invalid credentials')
    }

    const isPasswordValid = await hash.verify(user.password, password)
    if (!isPasswordValid) {
      return response.abort('Invalid credentials')
    }

    // log user in...
  }
}

Both error paths return the same message: Invalid credentials. No hint of whether the email existed or the password was wrong. Looks solid.

It isn't.

What is a timing attack?

A timing attack is a type of side-channel attack. Instead of exploiting a bug in your logic, it exploits information leaked by how long your code takes to execute.

The key insight is that not all code paths execute in the same time. A function that exits early on a mismatch returns faster than one that runs to completion. An attacker who can measure those differences, even at the millisecond level, can use them to make inferences about your system's internal state.

The classic example is a string comparison. Imagine a function that checks an API key character by character and returns false the moment it finds a mismatch:

stored:   "s3cr3t-k3y-abc"
attempt1: "s3cr3t-k3y-xyz"  → mismatch on character 12 → returns in ~1.2ms
attempt2: "xxxxxxxxxxxxxxx"  → mismatch on character 1  → returns in ~0.3ms

The attacker can't see inside your system, but they can measure response times from the outside. Attempt 1 consistently takes longer than Attempt 2, which tells them the first 11 characters of Attempt 1 were correct. Repeat this process one character at a time, and you can reconstruct a secret value without ever knowing the correct answer upfront.

This sounds theoretical, but it's been demonstrated against real web applications over standard networks. Attackers reduce noise by running the measurement from the same cloud region as the target, shrinking the interference from network latency.

The specific vulnerability in your login flow

Back to the login controller. The problem isn't the string comparison — it's something coarser and more visible.

Password hashing algorithms like argon2, bcrypt, and scrypt are intentionally slow. They're designed that way. Argon2 with default settings in AdonisJS might take 60–100ms to compute a hash. That's the point, as it makes offline brute-force attacks expensive.

But it also creates a timing signal in your login flow.

When a user submits credentials, two things can go wrong:

1. The email doesn't exist in the database — in which case your controller returns immediately, with no hashing involved. Response time: a few milliseconds. 2. The email exists, but the password is wrong — in which case your controller runs hash.verify(), which takes 60–100ms before returning. Response time: significantly longer.

Both paths return “Invalid credentials”. But they take very different amounts of time to do so.

An attacker who submits a list of email addresses and measures response times will be able to separate the “doesn't exist” responses from the “exists but wrong password” responses just from the timing difference. They now have a list of confirmed valid email addresses on your platform. That list becomes the input for a targeted password attack.

This is a well-known class of vulnerability, and it shows up in real systems more often than you’d expect.

How AdonisJS solves it: the AuthFinder mixin

AdonisJS provides the AuthFinder mixin specifically to eliminate this timing vulnerability. You apply it to your User model, and it replaces your manual find-then-verify flow with a single method that handles both operations in a timing-safe way.

Setting up the mixin

import { DateTime } from 'luxon'
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import hash from '@adonisjs/core/services/hash'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'

const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
  uids: ['email'],
  passwordColumnName: 'password',
})

export default class User extends compose(BaseModel, AuthFinder) {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare fullName: string | null

  @column()
  declare email: string

  @column()
  declare password: string

  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime
}

withAuthFinder takes two arguments. The first is a callback returning the hasher to use — scrypt in this case, though you can swap it for argon2 or bcrypt. The second is a configuration object:

  • uids: the model properties that can identify a user. You can include email, username, or both.
  • passwordColumnName: the model property that holds the hashed password.

The mixin also registers a beforeSave hook that automatically hashes the password before any INSERT or UPDATE operation. You assign a plain text value to user.password, and the hook handles the hashing, you never call hash.make() manually.

Using verifyCredentials

With the mixin applied, your controller becomes:

import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
  async store({ request }: HttpContext) {
    const { email, password } = request.only(['email', 'password'])

    const user = await User.verifyCredentials(email, password)

    // user is authenticated — log them in or issue a token
  }
}

One call. No manual null checks, no manual hash verification. If the credentials are invalid for any reason, verifyCredentials throws an E_INVALID_CREDENTIALS exception. That exception is self-handled by AdonisJS and converted into the appropriate HTTP response automatically.

What verifyCredentials actually does under the hood

The timing safety in verifyCredentials comes from two things working together.

When the user isn't found, the mixin still runs the password hashing operation before throwing E_INVALID_CREDENTIALS. The result is immediately discarded — nothing is compared. The sole purpose is to consume the same amount of time a real verification would take, so an attacker measuring response times can't tell the difference between “this email doesn't exist” and “this email exists, but the password was wrong.”

When the user is found, but the password is wrong, the comparison itself is done using a constant-time equality function built on Node's crypto.timingSafeEqual. Unlike a standard string comparison that exits the moment it finds a mismatch, this walks through the full comparison regardless of where the difference appears — leaking no information about how much of the hash matched.

Both paths take approximately the same time. There's nothing in the response timing for an attacker to work with.

Beyond authentication: where else this applies

The timing attack surface in a web application isn't limited to login flows. It shows up anywhere your code takes measurably different amounts of time depending on user input — whether that's a secret comparison that exits early, or an entire operation that runs conditionally based on whether something exists.

Password reset tokens

If you store a reset token in the database and compare it with a simple ===, you have the same class of vulnerability. AdonisJS ships a safeEqual helper that wraps crypto.timingSafeEqual with string support. Use it anywhere you're comparing secrets:

import { safeEqual } from '@adonisjs/core/helpers'

const isValid = safeEqual(storedToken, userProvidedToken)

API key verification

Same problem, same fix. Comparing keys with === leaks timing information about how many characters matched. safeEqual handles this correctly, including the buffer-length normalisation that raw crypto.timingSafeEqual requires you to manage yourself.

Whole-operation timing leaks

safeEqual protects individual comparisons, but some functionality leak timing at a coarser level, through the operations that run conditionally based on whether something exists. A password reset functionality is the classic case: finding a user and sending them a reset email takes significantly longer than finding nothing and returning immediately, even if the response message is identical either way. An attacker measuring response times can enumerate valid email addresses without touching the comparison logic at all.

AdonisJS provides safeTiming helper for this. It wraps a callback and guarantees the response takes at least a minimum number of milliseconds, regardless of the internal execution path:

import { safeTiming } from '@adonisjs/core/helpers'

return safeTiming(200, async () => {
  const user = await User.findBy('email', email)

  if (user) await sendResetEmail(user)

  return { message: 'If this email exists, you will receive a reset link.' }
})

Whether the user exists or not, the response always takes at least 200ms. For cases where you want constant time on failure but fast responses on success, safeTiming exposes a returnEarly() escape hatch:

return safeTiming(200, async (timing) => {
  const token = await Token.findBy('value', request.header('x-api-key'))

  if (token) {
    timing.returnEarly()

    return token.owner
  }

  throw new UnauthorizedException()
})

Valid tokens get a fast response. Invalid ones always wait the full 200ms. The minimum duration should be comfortably above the slowest realistic execution of the callback — if sending a reset email can take up to 150ms, a floor of 200ms gives you headroom while remaining imperceptible to real users.

Rate limiting as a complementary layer

Constant-time comparisons eliminate the timing signal, but they don't stop an attacker from making millions of guesses. Rate limiting makes the data collection phase of a timing attack impractical — an attacker needs hundreds or thousands of samples per guess to build a statistically reliable signal, and rate-limiting cuts off that sample collection. AdonisJS ships a rate-limiting package via @adonisjs/limiter that integrates directly into your middleware stack.

Conclusion

Timing attacks are easy to overlook because the vulnerability isn't in your logic — your error messages are identical, your intent is correct. The leak happens at the infrastructure level, in the timing of operations your code performs.

The practical lesson: don't roll your own credential verification. AdonisJS's AuthFinder mixin exists precisely because this is the kind of thing that's easy to get subtly wrong. It handles the timing-safe comparison, the hash on missing users, and the beforeSave password hashing hook — all in one composable that takes a few lines to apply. For operations beyond login, safeEqual and safeTiming give you the same guarantees at the helper level.