ksn135/htpasswd-bundle
Symfony bundle for HTTP Basic authentication backed by Apache-style htpasswd files. Protect routes with existing credentials, configure user file paths, and add htpasswd-based access control to Symfony applications.
Reality check first. ksn135/htpasswd-bundle is a Symfony2 bundle, not a Laravel package. There is no service provider, no facade, no config/htpasswd.php, and it targets PHP 5.3/5.4-era Symfony (2.x, long EOL). composer require ksn135/htpasswd-bundle in a Laravel app will install the code but nothing will boot it. What you actually get is ~3 small classes worth reading and porting:
user:hash lines,file path.So "getting started" means: clone the repo, read Security/User/, and re-implement it as a Laravel user provider in about 60 lines. The value is the htpasswd parser and the hash-format handling, not the bundle.
Minimal steps
composer require symfony/security-core is not needed in Laravel — you already have Illuminate\Contracts\Auth\UserProvider. Delete everything Symfony-specific from the port.app/Auth/HtpasswdUserProvider.php implementing Illuminate\Contracts\Auth\UserProvider..htpasswd file — typically one already shared with nginx/Apache, or generated by the htpasswd CLI for an ops team.Auth::provider('htpasswd', fn ($app, $config) => new HtpasswdUserProvider($config['file'])) in a service provider.config/auth.php as a provider and attach auth.basic to the route group.// config/auth.php
'guards' => [
'staging' => ['driver' => 'session', 'provider' => 'htpasswd'],
],
'providers' => [
'htpasswd' => ['driver' => 'htpasswd', 'file' => base_path('.htpasswd')],
],
// app/Providers/AuthServiceProvider.php boot()
Auth::provider('htpasswd', function ($app, array $config) {
return new HtpasswdUserProvider($config['file']);
});
Route::middleware(['auth.basic:staging'])->group(function () {
Route::get('/staging/{any?}', ...)->where('any', '.*');
});
First use case: you inherited an app (or a staging environment) protected by Apache/nginx auth_basic and a .htpasswd file, and you want the same credentials to work behind Laravel without migrating users into the users table. This is the only scenario where the package's design maps cleanly onto Laravel.
The provider contract is the whole job. retrieveById, retrieveByToken, updateRememberToken are effectively no-ops for a file-backed, stateless credential store. The two methods that matter:
public function retrieveByCredentials(array $credentials)
{
$entry = $this->entries()[$credentials['email'] ?? ''] ?? null;
return $entry ? new HtpasswdUser($credentials['email'], $entry) : null;
}
public function validateCredentials(UserContract $user, array $credentials)
{
return $this->check($credentials['password'], $user->getAuthPassword());
}
Return a lightweight Authenticatable implementation (or a GenericUser) — htpasswd carries no email, id, or name, only a username and a hash. Don't try to make it an Eloquent model.
Parse once per request, cache the file. The Symfony bundle re-reads the file on every authentication; do better:
protected function entries(): array
{
$mtime = @filemtime($this->file);
return Cache::remember("htpasswd:{$this->file}:{$mtime}", 3600, function () {
$out = [];
foreach (file($this->file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if ($line[0] === '#' || !str_contains($line, ':')) continue;
[$user, $hash] = explode(':', $line, 2);
$out[trim($user)] = trim($hash);
}
return $out;
});
}
Keying the cache on filemtime gives you free invalidation when ops rotates the file, which is the normal operating model.
Hash dispatch is the real logic. htpasswd files accumulate formats over years. Handle them in order:
protected function check(string $plain, string $stored): bool
{
if (str_starts_with($stored, '$2y$') || str_starts_with($stored, '$2a$')) {
return password_verify($plain, $stored);
}
if (str_starts_with($stored, '$apr1$')) {
return hash_equals($stored, $this->apr1($plain, $stored)); // Apache MD5, not crypt()
}
if (str_starts_with($stored, '{SHA}')) {
return hash_equals($stored, '{SHA}'.base64_encode(sha1($plain, true)));
}
if (str_starts_with($stored, '$1$')) {
return hash_equals($stored, crypt($plain, $stored)); // MD5 crypt, glibc-only
}
return hash_equals($stored, crypt($plain, substr($stored, 0, 2))); // legacy DES
}
$apr1$ is the one that bites everyone: it is not PHP's crypt() — you need a straight port of Apache's apr_md5_encode (about 30 lines, widely available as a gist). Budget half your implementation time for this if the file was generated by htpasswd -m.
Writing entries. Don't shell out to the htpasswd binary from PHP. Generate bcrypt and append atomically, under a lock:
Artisan::command('htpasswd:add {user} {--file=.htpasswd}', function () {
$hash = password_hash($this->secret('Password'), PASSWORD_BCRYPT);
file_put_contents(base_path($this->option('file')), "{$this->argument('user')}:{$hash}\n", FILE_APPEND | LOCK_EX);
});
Integration tips
auth.basic sends the WWW-Authenticate challenge; if you need a custom realm, subclass AuthenticateWithBasicAuth or set the header via a small middleware.retrieveById returns the same GenericUser for a username — keep the username as the getAuthIdentifier().throttle: on the login routes; htpasswd files have no lockout and basic auth is a brute-force magnet.It's a dead Symfony2 bundle. Symfony 2.x is EOL and unpatched; the code assumes old PSR-0-ish namespacing and Symfony\Component\Security\Core\* interfaces that have since changed signatures (UserProviderInterface::refreshUser, supportsClass, etc.). Do not attempt to run the bundle "inside" Laravel or bridge it via a compatibility shim — the ROI is negative for ~60 lines of logic. Fork and port.
$2y$ vs $2x$. Old Apache/PHP emitted $2x$ in a few edge cases; password_verify rejects it. If you see $2x$ in the file, normalize to $2y$ before verifying — the algorithms are identical for ASCII passwords.
crypt() is platform-dependent. MD5/DES crypt relies on glibc; it's absent or different on some musl/Alpine builds. If you deploy on Alpine, implement MD5-crypt in pure PHP or reject $1$/DES entries at parse time with a clear error rather than silently failing auth.
Constant-time comparison. hash_equals everywhere, including for the plaintext-crypt branch. The original bundle-era code frequently used == or crypt() === crypt(), which is timing-leaky. This matters more than usual because basic auth has no rate limit by default.
Never let the file be web-reachable. base_path('.htpasswd') is correct; public/.htpasswd is an instant credential dump on misconfigured nginx. If the file must live under the docroot for legacy reasons, add an explicit deny rule and a test asserting a 403/404.
HTTPS only. Basic auth sends user:pass base64-encoded on every request. There is no negotiation, no token, no rotation. Gate auth.basic behind ->middleware('throttle:10,1') and, ideally, an IP allowlist for staging.
Blank lines, comments, CRLF. FILE_IGNORE_NEW_LINES handles \n but not a trailing \r — trim() each field. Lines without : (htpasswd tolerates junk) should be skipped, not crash the parser. A single malformed line should not lock out the whole file.
Cache invalidation is the #1 support ticket. filemtime has 1-second granularity and opcache can hide changes; if ops edits the file in place within the same second, you'll serve the stale hash. For a rotating file, write-then-rename (new inode, new mtime) rather than in-place edit.
Extension point worth keeping from the bundle: it separates "read entries from file" from "verify hash". Preserve that split in your port — swap the store for a Redis-backed user:hash map later without touching verification logic, and unit-test the hash dispatch table in isolation with fixture hashes from real htpasswd output (-m, -B, -s, -p).
When not to use this at all. If you're greenfield, use users + Hash::make and a normal guard. htpasswd's only genuine advantages are interoperability with Apache/nginx and zero-database bootstrapping. The moment you need password reset, roles, or audit logging, the file format actively fights you — colon-delimited lines are not a schema.
How can I help you explore Laravel packages today?