Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Htpasswd Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  • a user provider that reads a file of user:hash lines,
  • an authentication provider that checks a submitted password against the stored hash,
  • a DependencyInjection extension that takes a single 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

  1. composer require symfony/security-core is not needed in Laravel — you already have Illuminate\Contracts\Auth\UserProvider. Delete everything Symfony-specific from the port.
  2. Create app/Auth/HtpasswdUserProvider.php implementing Illuminate\Contracts\Auth\UserProvider.
  3. Point it at an existing .htpasswd file — typically one already shared with nginx/Apache, or generated by the htpasswd CLI for an ops team.
  4. Register it via Auth::provider('htpasswd', fn ($app, $config) => new HtpasswdUserProvider($config['file'])) in a service provider.
  5. Wire it into 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.

Implementation Patterns

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.
  • Remember-me and session login work fine once retrieveById returns the same GenericUser for a username — keep the username as the getAuthIdentifier().
  • Combine with throttle: on the login routes; htpasswd files have no lockout and basic auth is a brute-force magnet.
  • If you serve both nginx basic auth and Laravel on the same file, treat the file as read-only from Laravel's side and rotate it via the ops pipeline. Two writers means clobbered entries.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource