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.3msThe 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:
- The email doesn't exist in the database — in which case your controller returns immediately, with no hashing involved. Response time: a few milliseconds.
- 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 includeemail,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 log in flows. Any place where you compare a secret value against user-provided input is potentially vulnerable if that comparison can exit early.
Two common examples worth knowing:
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. The fix is the same: use crypto.timingSafeEqual when comparing the token from the request against the stored value.
API key verification. If you're building an API that authenticates via keys, comparing keys with === leaks timing information about how many characters matched. Again, timingSafeEqual is the correct tool.
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.