Repository navigation
Description
AuthService.login rejects an unknown username faster than a wrong password for a real one, so response time shows which usernames exist. For an unknown username it throws 401 as soon as the lookup fails. For a real username it first runs CryptoService.comparePassword, a 100,000-iteration PBKDF2-SHA512 derivation. That took about 20 ms per call on an Apple M-series core and takes longer on a typical server vCPU. Both responses have the same body, but the difference is measurable from outside. An unauthenticated client can therefore build a list of valid usernames and aim its password guesses at them. The comment beside the check says the intent is that "a wrong guess cannot reveal whether a username exists", and the timing difference breaks that.
Where
apps/api/src/auth/auth.service.ts:45-59:
try {
user = await this.usersService.findByUsername(credentials.username, { includeHashedPassword: true });
} catch (err) {
if (err instanceof NotFoundException) {
throw new UnauthorizedException('Invalid Credentials');
}
throw err;
}
// Account status is checked only once the password is proven, so a wrong guess cannot reveal
// whether a username exists or has been archived.
const isCorrectPassword = await this.cryptoService.comparePassword(credentials.password, user.hashedPassword);CryptoService.comparePassword (libnest src/modules/crypto/crypto.service.ts) runs pbkdf2(password, salt, 100_000, 64, 'sha512').
Reproduce
- Time several
POST /v1/auth/loginrequests with{ "username": "admin", "password": "wrong" }. - Time several with
{ "username": "no-such-user", "password": "wrong" }.
Actual: both return 401 Invalid Credentials, but every request in the first set takes one PBKDF2 derivation longer than those in the second (about 20 ms measured locally).
Expected: an unknown username takes as long as a wrong password, so the response time does not show whether the account exists.
Tests
apps/api/src/auth/__tests__/auth.service.test.ts: it('should verify the password against a dummy hash when the username does not exist, so response time does not reveal which usernames exist'). Assert that cryptoService.comparePassword is called when findByUsername rejects with NotFoundException, and that the result is still 401. A Playwright test cannot measure timing reliably, so the unit test is the guard.
Suggested fix
In the NotFoundException branch, call this.cryptoService.comparePassword(credentials.password, dummyHash) before throwing. dummyHash should be produced once with cryptoService.hashPassword (for example cached on the service on first use), so its cost matches a real stored hash.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e).AuthService.loginis unchanged since this was filed, and no open PR touchesauth.service.tsorusers.service.ts. No other unauthenticated route looks up a user: the only@RouteAccess('public')handlers arePOST /v1/auth/loginandGET/POST /v1/setup, andGET /v1/users/check-username/:usernameneedsread User.The dummy hash can be a constant
comparePasswordcosts the same for any well-formed stored hash. The iteration count comes from theCryptoServiceinstance (libnestsrc/modules/crypto/crypto.module.ts:16: 100,000, or 1 underDANGEROUSLY_DISABLE_PBKDF2_ITERATION). The stored value is onlyhash$salt(crypto.service.ts:20and:34). So a module constant can replace the cachedhashPasswordresult, with no first-use state:The
$is required. Without itsaltisundefined, Node'spbkdf2throwsERR_INVALID_ARG_TYPE, and an unknown username gets a 500 instead of a 401. That is a plainer oracle than the timing. libnest 10.0.0, which #1660 moves to, ships the sameCryptoService.Verify on one path
Instead of a second
comparePasswordcall in thecatch, have theNotFoundExceptionbranch setuser = nulland verify once:Then no path reaches the 401 without the derivation.
A smaller gap remains
findByUsernamerunsinclude: { groups: true }(users.service.ts:159), so a real username also loads its group documents before the password is checked, and a miss loads none. The dummy hash does not remove that difference, and it grows with the group documents (#1534). Closing it means verifying against a lookup without theincludeand loading the groups only once the password is proven. The fix for #1534 already changes whatfindByUsernameloads for the token, so do it there. Land this issue first, since both editAuthService.login.Tests
apps/api/src/auth/__tests__/auth.service.test.ts: add the test the issue names beside the unknown-username test at line 113, asserting thatcomparePasswordruns exactly once for the miss.it('should answer an unknown username with 401 under a real CryptoService, so a malformed dummy hash cannot turn the miss into a 500'). Build that test'sAuthServicewithnew CryptoService({ pbkdf2Params: { iterations: 1 }, secretKey: 'test' }). The rest of the file mocksCryptoService, so nothing else there catches a dummy without the$.testing/src/specs/auth.spec.ts:99-109already sends an unknown username through the real API and expects theUnauthorizedheading. A 500 fails it, becauselogin.tsxaccepts only 200, 401 and 403. Add an API-level test beside it, usingrequest.postasauthorization.spec.tsdoes, asserting that an unknown username and a wrong password for anapi.createUser()account both return 401 with the same body.Triage note written with Claude Code. Code references point at
main@d86740e.