Custom authentication in Laravel

  • avatar
  • 90 Views
  • 10 mins read

Laravel ships with a solid authentication system out of the box, and most projects are well served by it. But there are situations where you need to authenticate users against something completely different: a legacy database, an external API, an LDAP server, or some other custom data source. Laravel's authentication system is built around a set of contracts and driver hooks that make this surprisingly straightforward to implement.

This article walks through building a custom authentication guard and provider in Laravel using a plain text file as the user store. It's not something you'd ship to production, but it's a clean, dependency-free way to understand exactly how the pieces fit together.

How Laravel authentication works under the hood

Before writing any code, it helps to know what Laravel's auth system actually does at a structural level.

Authentication in Laravel is managed by the Auth facade, which delegates to an AuthManager. The manager holds named guards, and each guard references a named provider. Both guards and providers are configured in config/auth.php, and both have a driver key that defines their type.

There are two distinct concepts worth keeping separate:

  • Guard: a named instance defined under guards in config/auth.php, for example web or custom. Each guard has a driver that controls how authentication state is tracked across requests. The built-in guard drivers are session (stores the user ID in the session) and token (reads a token from the request). This is what you reference in middleware and Auth::guard() calls.

  • Provider: a named instance defined under providers in config/auth.php, for example users or custom_auth_provider. Each provider has a driver that controls how users are retrieved from a data source. The built-in provider drivers are eloquent and database. Each guard references one provider by name.

The default setup illustrates this clearly: the web guard uses the session driver and references the users provider, which uses the eloquent driver backed by the User model. Everything in this article follows the same shape, just with a custom provider driver on the provider side.

Setting up the text file user store

For this example, users are stored in a plain text file where each line contains an email address and a hashed password, separated by a colon:

[email protected]:$2y$12$abc123hashedpassword...
[email protected]:$2y$12$xyz789hashedpassword...

You can generate a bcrypt hash for testing from Tinker:

php artisan tinker
>>> bcrypt('secret')

Store this file somewhere outside the public directory, for example at storage/app/users.

Configuring the guard and provider

Before implementing anything, it helps to see the target configuration. Open config/auth.php and add a new guard and a new provider. Leave the existing web guard and users provider untouched:

'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],

'custom' => [
'driver' => 'session',
'provider' => 'custom_auth_provider',
],
],

'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\\Models\\User::class,
],

'custom_auth_provider' => [
'driver' => 'custom_file',
'path' => storage_path('app/users'),
],
],

The custom guard reuses the built-in session driver, exactly like the web guard does. There's no need to write a custom guard class just to manage session state, since that behaviour is already covered. What's custom here is the provider side: the custom_auth_provider provider uses the custom_file driver, which you'll register via Auth::provider. The path key is custom configuration that gets passed to the provider factory at runtime.

Implementing the provider

The custom_file provider driver needs a provider class implementing UserProvider. It returns instances of App\\Models\\User, which already satisfies the Authenticatable contract via the Illuminate\\Auth\\Authenticatable trait that Laravel includes in the model by default. No changes to the User model are needed.

Create the provider at app/Providers/TextFileUserProvider.php:

<?php

namespace App\\Providers;

use App\\Models\\User;
use Illuminate\\Contracts\\Auth\\Authenticatable;
use Illuminate\\Contracts\\Auth\\UserProvider;
use Illuminate\\Support\\Facades\\Hash;

class TextFileUserProvider implements UserProvider
{
public function retrieveById($identifier): ?Authenticatable
{
$users = $this->loadUsers();

if (!isset($users[$identifier])) {
return null;
}

return new User(['email' => $identifier, 'password' => $users[$identifier]]);
}

public function retrieveByToken($identifier, $token): ?Authenticatable
{
return null;
}

public function updateRememberToken(Authenticatable $user, $token): void
{
}

public function retrieveByCredentials(array $credentials): ?Authenticatable
{
$users = $this->loadUsers();
$email = $credentials['email'] ?? null;

if (!$email || !isset($users[$email])) {
return null;
}

return new User(['email' => $email, 'password' => $users[$email]]);
}

public function validateCredentials(Authenticatable $user, array $credentials): bool
{
return Hash::check($credentials['password'], $user->getAuthPassword());
}

public function rehashPasswordIfRequired(Authenticatable $user, array $credentials, bool $force = false): void
{
}

protected function loadUsers(): array
{
$file = config('auth.providers.custom_auth_provider.path');

if (!file_exists($file)) {
return [];
}

$users = [];
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

foreach ($lines as $line) {
[$email, $hash] = explode(':', $line, 2);
$users[$email] = $hash;
}

return $users;
}
}

retrieveByCredentials locates the user by email in the file, and validateCredentials checks the submitted password against the stored bcrypt hash. These two methods are called in sequence by the guard during a login attempt. The User instances constructed here are not persisted to the database; they are plain in-memory objects used only to carry the identity and password hash through the auth flow.

Since the text file store has no numeric ID, the User model needs to use email as its auth identifier instead of the default id. Without this, the session guard stores null as the identifier after login and can never retrieve the user on subsequent requests. Override getAuthIdentifierName in App\\Models\\User:

public function getAuthIdentifierName(): string
{
return 'email';
}

This tells the session guard to store and look up users by email across requests, which aligns with what the text file provider uses as its key.

Registering the provider

With the provider class in place, the custom_file provider driver needs to be registered with Laravel's AuthManager so it knows how to build a TextFileUserProvider instance when the custom_auth_provider provider is resolved. Add the following to the boot method of App\\Providers\\AppServiceProvider:

<?php

namespace App\\Providers;

use Illuminate\\Contracts\\Foundation\\Application;
use Illuminate\\Support\\Facades\\Auth;
use Illuminate\\Support\\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
Auth::provider('custom_file', function (Application $app, array $config) {
return new TextFileUserProvider();
});
}

public function boot(): void
{
//
}
}

Auth::provider('custom_file', ...) registers the factory for the custom_file provider driver. When the custom guard is resolved and needs its provider, the AuthManager looks up custom_auth_provider in config, sees the custom_file driver, calls this factory with the provider config array (including the path key), and gets back a ready-to-use TextFileUserProvider instance. The driver name custom_file must match exactly what is set in the driver key of the custom_auth_provider config entry.

Using the guard

With everything wired up, you reference the custom guard by name anywhere in the application. To test the implementation, start by defining a set of routes in routes/web.php:

<?php

use Illuminate\\Support\\Facades\\Route;
use App\\Http\\Controllers\\LoginController;

Route::get('/login', [LoginController::class, 'login'])->name('login');
Route::get('/logout', [LoginController::class, 'logout'])->name('logout');

Route::get('/public', function () {
return 'This is a public page, anyone can see it.';
});

Route::middleware(['auth:custom'])->group(function () {
Route::get('/private', function () {
return 'This is a private page, only authenticated users can see it.';
});
});

Hitting /public works for anyone. Hitting /private without being authenticated redirects to the login route. The login route accepts credentials as query parameters purely for testing convenience, in a real application you'd never pass credentials through the URL, as they end up in server logs and browser history. With the route in place, you can test directly from the browser or a tool like curl:

GET /[email protected]&password=secret

The login and logout logic lives in a dedicated controller:

<?php

namespace App\\Http\\Controllers;

use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;

class LoginController extends Controller
{
public function login(Request $request)
{
$credentials = $request->only('email', 'password');

if (Auth::guard('custom')->attempt($credentials)) {
return redirect('/private');
}

return 'Invalid credentials';
}

public function logout()
{
Auth::guard('custom')->logout();

return redirect('/public');
}
}

Auth::guard('custom') resolves the session-backed guard instance configured to use custom_auth_provider. From there, attempt, user, check, and every other guard method work exactly as they do on the web guard.

If you want custom to be the application-wide default, update the defaults section in config/auth.php:

'defaults' => [
'guard' => 'custom',
'passwords' => 'users',
],

With that in place, Auth::attempt(), Auth::user(), and auth middleware without arguments all resolve to the custom guard automatically.

Extending further

The same pattern scales up to more complex scenarios. Authenticating against an external REST API means retrieveByCredentials makes an HTTP call instead of reading a file. Authenticating against LDAP means you swap in an LDAP lookup. The provider driver contract stays the same regardless of the underlying mechanism, and you can define multiple named providers in config/auth.php all backed by the same custom_file driver, each pointing at a different file.

If you do need a fully custom guard driver as well, for example to handle stateless token authentication for an API, you'd register it via Auth::extend and implement Illuminate\\Contracts\\Auth\\Guard.

Conclusion

Laravel's authentication system is far more open than it might appear at first. Guards manage auth state via a driver, providers retrieve users via their own driver, and both are independently configurable in config/auth.php. In many cases, including this one, the built-in session guard driver is perfectly reusable and only the provider side needs custom work. By registering a custom provider driver through a service provider and wiring it up as a named provider in config, you get full compatibility with Auth::attempt, Auth::user, route middleware, and everything else that builds on top of the auth system. The text file example is intentionally minimal, but the structure it demonstrates applies directly to any custom authentication scenario you'll run into in practice.

Credits

Official GitHub: https://github.com/hibit-dev/laravel13-auth

 Join Our Monthly Newsletter

Get the latest news and popular articles to your inbox every month

We never send SPAM nor unsolicited emails

0 Comments

Leave a Reply

Your email address will not be published.

Replying to the message: View original

Hey visitor! Unlock access to featured articles, remove ads and much more - it's free.