Account enumeration is an attacker learning which usernames exist without learning any password. That alone is worth something: it confirms a person holds an account on your site, and it narrows a credential-stuffing list down to addresses worth attacking. A login form can give this away through three separate channels, and closing one does nothing for the others.
Aura.Auth closes the channel it owns. You own the other two.
Verifying a password is deliberately slow — that is what bcrypt is for. So a naive login costs very different amounts of work depending on whether the username was found:
- username exists, password wrong — a query, then a full bcrypt verification: on the order of 100–300ms.
- username does not exist — a query that matches nothing, and an immediate failure: on the order of 1ms.
An attacker does not need an error message to tell those apart. They submit a list of candidate addresses with a junk password and keep the slow ones. That is a two-hundred-fold difference, readable straight through internet jitter.
PdoAdapter and HtpasswdAdapter therefore run a throwaway verification
against a dummy hash when no account matched, so both outcomes cost about the
same before the same exception is thrown. This is not something an application
can fix from the outside, because the difference is created inside login().
The network adapters are a separate case, covered in Response Time on LDAP and
IMAP below.
Three things are worth knowing about how it works.
It does not rely on the dummy hash being secret. AbstractAdapter exposes
fallback hashes as public constants and this documentation describes the
mechanism exactly. An attacker who knows all of it still cannot make the two
paths take different amounts of time — the signal is gone, not hidden.
The dummy comes from the verifier, so it matches the format you store. This
is the part that is easy to get wrong. A bcrypt dummy equalises the two paths
only when the stored hashes are also bcrypt. Put one in front of an htpasswd
file holding $apr1$ or {SHA} entries, or a column holding legacy hash()
digests, and it does not close the gap — it inverts it, and widens it. Those
formats verify in microseconds, so the unknown username becomes the slow answer
by a far larger margin than the original bug:
| stored format | wrong password | unknown username, bcrypt dummy |
|---|---|---|
| bcrypt, cost 12 | 259 ms | 265 ms |
hash('sha256', …) |
0.001 ms | 261 ms |
htpasswd $apr1$ |
0.0002 ms | 261 ms |
Only the verifier knows which format it reads, so the verifier supplies the
dummy. Both stock verifiers implement Verifier\DummyHashInterface:
PasswordVerifier builds one from its configured algorithm and options, and
HtpasswdVerifier from the format named in its constructor. With the dummy
format-matched, every row above comes back to a ratio of about 1.
So the knob for pinning the cost is the verifier, not the adapter:
<?php
// the dummy follows automatically -- cost 13 in, cost 13 dummy out
$verifier = new \Aura\Auth\Verifier\PasswordVerifier(
PASSWORD_BCRYPT,
array('cost' => 13)
);
// htpasswd -B writes bcrypt; the default here is apr1, what plain htpasswd writes
$verifier = new \Aura\Auth\Verifier\HtpasswdVerifier('bcrypt');
// a `$2y$` hash encodes its cost, and htpasswd -B writes cost 5 by default
// (`-C` sets it) where PHP writes 10, or 12 from PHP 8.4 on -- so a bcrypt
// htpasswd file needs the cost pinned too, or the dummy costs ~128x the
// wrong-password path and the signal is back, pointing the other way
$verifier = new \Aura\Auth\Verifier\HtpasswdVerifier('bcrypt', array('cost' => 5));
?>HtpasswdVerifier's format argument affects nothing but the dummy — verify()
still dispatches per entry, so a file mixing formats still authenticates
everyone. Set it to whatever the bulk of the file holds, since it is the cost of
the typical entry that the unknown-username path has to match.
AbstractAdapter::getDummyHash() remains as a fallback for verifiers that do
not implement DummyHashInterface, and still selects by PHP's default bcrypt
cost — 10 before PHP 8.4, 12 from 8.4 on. A custom verifier can either implement
the interface or leave the adapter to override:
<?php
class MyPdoAdapter extends \Aura\Auth\Adapter\PdoAdapter
{
protected function getDummyHash(): string
{
// generated once with password_hash() at the same cost as the hashes
// in the accounts table; the plaintext was never recorded
return '$2y$13$...';
}
}
?>A dummy must be valid, and its plaintext unknown. On the password_verify()
and crypt() paths a malformed hash is rejected without any hashing work, which
silently restores the timing difference; and a dummy whose plaintext someone
knows becomes a working password the moment the value is copied into a password
column. Generating it from random_bytes() and never recording the input
satisfies both, which is what the stock verifiers do. (The legacy hash() path
is the exception to the first half: it digests the submitted password before
comparing, so a malformed stored value still costs the same — the second half,
the unknown plaintext, applies there as much as anywhere.)
If the costs do not match exactly, a proportional difference remains. Login throttling is what covers that residue: reading a small timing difference takes many samples per username, and the backoff in Login Throttling makes collecting them impractical.
Everything above belongs to the two adapters that verify a hash themselves,
PdoAdapter and HtpasswdAdapter. LdapAdapter verifies nothing — the
directory does — and one of its two modes still answers an unknown username
faster than a wrong password.
| mode | unknown username | wrong password |
|---|---|---|
direct bind (no search) |
one bind | one bind |
bind-search-rebind (search set) |
search only | search, then a second bind |
Direct bind is uniform. The username goes into the distinguished name (DN)
template — an entry's full path in the directory tree, such as
uid=%s,ou=people,dc=example,dc=com — and the adapter binds once; an unknown
user and a wrong password both fail that same single bind, so the library does
identical work either way.
Bind-search-rebind is not. A search matching nothing throws
UsernameNotFound straight away, while a search that matches costs another
round trip — the rebind as the discovered user — before BindFailed. The gap is
roughly one LDAP round trip.
It is deliberately not patched the way the hash adapters are, because the obvious cure is plausibly worse than the disease. A throwaway bind against a deliberately nonexistent DN would send the submitted password to the directory and land a failed-bind record in its audit log on every unknown username. Directories commonly drive intruder detection and account lockout from exactly that signal, so manufacturing failed binds at attacker-controlled volume is a good way to cause an outage. It might not even work: a server that rejects an unknown DN before comparing credentials short-circuits, leaving you with the audit-log cost and the leak both.
What to do about it, in order of preference. Use direct bind where your directory structure allows a DN template, since that mode has no gap. Otherwise rely on Login Throttling: one LAN round trip is a far smaller and noisier signal than a bcrypt verification — milliseconds against hundreds of milliseconds, read through network jitter — so it takes many samples per username, and backoff is what makes collecting them impractical.
ImapAdapter draws no distinction of its own: it makes a single imap_open()
call and reports every failure as ConnectionFailed. Whether your IMAP server
rejects an unknown mailbox faster than a bad password is the server's behaviour,
and outside the library's reach.
None of the above matters if the response says which part failed.
Adapters throw distinct exceptions — UsernameNotFound when no account
matched, PasswordIncorrect when the password did not verify. That distinction
is useful for your logs. Shown to the user, it hands over exactly what the
timing fix was protecting, and far more cheaply: no statistics required, just
read the page.
Catch the base Aura\Auth\Exception and render one message for every failure:
<?php
use Aura\Auth\Exception as AuthException;
try {
$login_service->login($auth, array(
'username' => $_POST['username'],
'password' => $_POST['password'],
));
} catch (AuthException $e) {
// log the specific exception; tell the user nothing specific
$logger->info('login failed: ' . get_class($e));
echo "Invalid username or password.";
}
?>The same applies to anything else that varies with the outcome: HTTP status codes, redirect targets, validation errors rendered next to one field rather than the other, and response bodies whose length differs.
Login is rarely the easiest enumeration target. Password reset, registration, and "resend confirmation" commonly answer "no account with that email" outright. Those flows live in your application, not in Aura.Auth, and they deserve the same generic response: if that account exists, we have sent it an email.
Verifiers compare hashes with hash_equals() rather than ===, so comparison
time does not depend on how many leading characters matched. PasswordVerifier
delegates to password_verify() for bcrypt, which is constant-time already.
Custom verifiers implementing VerifierInterface should use hash_equals()
for the same reason.
A stack trace records the arguments to every frame on it. Without precautions, one exception thrown anywhere below a login call writes the plaintext password into the trace, and traces go on to reach log files, error reporters, and — on a misconfigured host — the response body itself. The password is then sitting in plain text in several systems that were never meant to hold it, typically with wider access than the password database has.
Every parameter in this library that carries a credential is marked
#[\SensitiveParameter], so PHP replaces its value with
Object(SensitiveParameterValue) wherever it appears in a trace. That covers
the plaintext password, the $input array that holds it, the LDAP bind
password, the OAuth access token, and API token values.
Two consequences worth knowing:
-
The attribute is not inherited. If you write your own
VerifierInterface,RehashStorageInterface, orAdapterInterfaceimplementation, PHP does not copy the attribute down from the interface — repeat it on your own parameters, or your implementation reintroduces the leak for the whole call chain below it. -
It protects traces, not everything else. A credential you log yourself, put in an exception message, or store in the session is unaffected. In particular, do not include
$inputin your own log lines on a failed login.
The same non-inheritance rule reaches one case the library cannot mark for you.
OAuth2Adapter's map option is your own callback, and PHP does not redact the
arguments of a frame your code declared — so if your callback throws, the access
token appears in its frame even though mapOwner() redacted the copy in the
library's. Mark it yourself:
$adapter = $auth_factory->newOAuth2Adapter($provider, [
'map' => function (array $owner, #[\SensitiveParameter] $token) {
return [$owner['email'], $owner];
},
]);A custom ProviderInterface implementation needs the same treatment on
getAccessToken() and getResourceOwner(): the token exchange is a remote call
and one of the likelier things in an OAuth login to throw, which makes it one of
the likelier frames to end up in a trace holding an authorization code.
The rule reaches one more case, and this one no amount of marking on our side
closes. LeagueProvider hands the authorization code and the access token to
league/oauth2-client, which does not mark its own parameters. When something
below AbstractProvider::getAccessToken() or getResourceOwner() throws — a
network failure, a rejected grant — League's own frames are on the trace with
those values in the clear, even though LeagueProvider's frames redacted them.
The PKCE verifier is reachable the same way: League holds it on the provider,
but copies it into the request parameters, so it is a frame argument for as
long as the request is being built. So the guarantee stops at the package
boundary: with zend.exception_ignore_args off, treat a trace from a failed
League call as containing the code, the verifier and the token, and keep such
traces out of logs and responses.
If zend.exception_ignore_args is on (the default in PHP's production INI),
traces carry no arguments at all and this is moot. It is off in the development
INI, which is exactly where traces are most likely to be displayed.
Constant-time comparison is only worth having if what is being compared is
worth protecting. new PasswordVerifier('md5') — or any other hash()
algorithm name — compares an unsalted digest with no work factor, which is
recoverable in bulk regardless of how carefully it is compared.
That configuration exists only to let a site with a legacy password column
authenticate users while migrating them. New applications should use
new PasswordVerifier(PASSWORD_BCRYPT).
Migrating ones should rehash on each successful login — the only moment the
plaintext is available — until the old digests are gone. needsRehash() on
the adapter reports when the hash that just verified should be replaced, which
covers a raised bcrypt cost as well as legacy digests. See Rehashing Stored
Passwords for the recipe.