Create secure link for access to private data or login in Laravel without password
cesargb/laravel-magiclink is a Laravel package for create secure link for access to private data or login in laravel without password.
It currently has 468 GitHub stars and 1.749.548 downloads on Packagist (latest version v2.29.0).
Install it with composer require cesargb/laravel-magiclink.
Discover more Laravel packages by cesargb
or browse all Laravel packages to compare alternatives.
Last updated
Through the MagicLink class we can create a secure link that later
being visited will perform certain actions, which will allow us
offer secure content and even log in to the application.
[!TIP] Upgrading? See the CHANGELOG for what changed and any required migration steps.
You can install this package via composer using:
composer require cesargb/laravel-magiclink
You need to publish the migration to create the magic_links table:
php artisan vendor:publish --provider="MagicLink\MagicLinkServiceProvider" --tag="migrations"
After that, you need to run migrations.
php artisan migrate
With this example you can create a link to auto login on your application with the desired user:
use MagicLink\Actions\LoginAction;
use MagicLink\MagicLink;
$urlToAutoLogin = MagicLink::create(new LoginAction($user))->url
The MagicLink class has the create method to generate a class that through
the url property we will obtain the link that we will send to our visitor.
This method requires the action to be performed.
Each MagicLink is associated with an action, which is what will be performed once the link is visited.
Through the LoginAction action, you can log in to the application using the
generated link by MagicLink.
Your constructor supports the user who will login. Optionally we can specify
the HTTP response using the method
response or specify other guard with method guard.
Examples:
use MagicLink\Actions\LoginAction;
use MagicLink\MagicLink;
// Sample 1; Login and redirect to dash board
$action = new LoginAction(User::first());
$action->response(redirect('/dashboard'));
$urlToDashBoard = MagicLink::create($action)->url;
// Sample 2; Login and view forms to password reset and use guard web
$action = new LoginAction(User::first());
$action->response(view('password.reset', ['email' => '[email protected]']));
$urlShowView = MagicLink::create($action)->url;
// Sample 3; Login in other guard and redirect default
$action = new LoginAction(User::first());
$action->guard('customguard');
$urlShowView = MagicLink::create($action)->url;
// Sample 4; Login and remember me
$action = new LoginAction(User::first());
$action->remember();
$urlShowView = MagicLink::create($action)->url;
This action, DownloadFileAction, permit create a link to download a private file.
The constructor require the file path.
Example:
use MagicLink\Actions\DownloadFileAction;
use MagicLink\MagicLink;
// Url to download the file storage_app('private_document.pdf')
$url = MagicLink::create(new DownloadFileAction('private_document.pdf'))->url;
// Download file with other file_name
$action = new DownloadFileAction('private_document.pdf', 'your_document.pdf');
$url = MagicLink::create($action)->url;
// Download file from other disk
$action = new DownloadFileAction('private_document.pdf')->disk('ftp');
$url = MagicLink::create($action)->url;
This action, InlineFileAction, permit create a link to show a private file
inline in the browser (e.g. a PDF or an image) instead of forcing its download.
It shares the same arguments and fluent methods than DownloadFileAction
(file path, file name, disk() and headers()).
Example:
use MagicLink\Actions\InlineFileAction;
use MagicLink\MagicLink;
// Url to show the file storage_app('private_document.pdf') in the browser
$url = MagicLink::create(new InlineFileAction('private_document.pdf'))->url;
// Show file with other file_name
$action = new InlineFileAction('private_document.pdf', 'your_document.pdf');
$url = MagicLink::create($action)->url;
// Show file from other disk
$action = new InlineFileAction('private_document.pdf')->disk('ftp');
$url = MagicLink::create($action)->url;
With the action ViewAction, you can provide access to the view. You can use
in his constructor the same arguments than method view of Laravel.
Example:
use MagicLink\Actions\ViewAction;
use MagicLink\MagicLink;
// Url to view a internal.blade.php
$url = MagicLink::create(new ViewAction('internal', [
'data' => 'Your private custom content',
]))->url;
Through the ResponseAction action we can access private content without need
login. Its constructor accepts as argument the
HTTP response
which will be the response of the request.
Examples:
use MagicLink\Actions\ResponseAction;
use MagicLink\MagicLink;
$action = new ResponseAction(function () {
Auth::login(User::first());
return redirect('/change_password');
});
$urlToCustomFunction = MagicLink::create($action)->url;
MagicLink can directly call a controller via the ControllerAction action.
The constructor requires one argument, the name of the controller class. With
the second argument can call any controller method, by default it will use the
__invoke method.
use MagicLink\Actions\ControllerAction;
use MagicLink\MagicLink;
// Call the method __invoke of the controller
$url = MagicLink::create(new ControllerAction(MyController::class))->url;
// Call the method show of the controller
$url = MagicLink::create(new ControllerAction(MyController::class, 'show'))->url;
You can create your own action class, for them you just need to extend with
MagicLink\Actions\ActionAbstract
use MagicLink\Actions\ActionAbstract;
class MyCustomAction extends ActionAbstract
{
public function __construct(public int $variable)
{
}
public function run()
{
// Do something
return response()->json([
'success' => true,
'data' => $this->variable,
]);
}
}
You can now generate a Magiclink with the custom action
use MagicLink\MagicLink;
$action = new MyCustomAction('Hello world');
$urlToCustomAction = MagicLink::create($action)->url;
[!TIP] Best Practice: We recommend using primitive types (
int,string,array, etc.) as properties instead of objects. Extract the necessary data from objects in the constructor and store only the primitive values.For example,
LoginActionreceives aUserobject but stores only theauthIdentifier(int/string) instead of the entire object. This approach is more secure and doesn't require configuring allowed classes.
When your custom action must contain object properties (like Eloquent models), you must explicitly allow those classes to be deserialized for security reasons. This prevents potential security vulnerabilities from arbitrary object instantiation.
Recommended approach (using primitive types):
use MagicLink\Actions\ActionAbstract;
use App\Models\User;
class SendWelcomeEmailAction extends ActionAbstract
{
public function __construct(public int $userId, public string $email)
{
}
public function run()
{
$user = User::find($this->userId);
// Send email to $user
return response('Email sent!');
}
}
// Usage
$action = new SendWelcomeEmailAction($user->id, $user->email);
MagicLink::create($action);
Alternative approach (using objects, requires configuration):
use MagicLink\Actions\ActionAbstract;
use App\Models\User;
class SendWelcomeEmailAction extends ActionAbstract
{
public function __construct(public User $user)
{
}
public function run()
{
// Send email to $this->user
return response('Email sent!');
}
}
To allow the User class to be deserialized, configure it in config/magiclink.php:
'allowed_classes' => [
\App\Models\User::class,
\App\Models\Post::class,
// Add any other classes your actions might use
],
Or use the MAGICLINK_ALLOWED_CLASSES environment variable with comma-separated class names:
MAGICLINK_ALLOWED_CLASSES="App\Models\User,App\Models\Post"
[!WARNING] If you use object properties in your custom actions without configuring allowed classes, you'll get a
TypeErrorwhen the MagicLink is accessed. Always add your classes to the allowed list.
To set the base URL for a magic link, you can use the baseUrl method. This method ensures that the provided base URL has a trailing slash, making it ready for URL generation.
$magiclink = MagicLink::create($action);
$magiclink->baseUrl("http://example.com");
$urlShowView = $magiclink->url; // http://example.com/magiclink/...
Optionally you can protect the resources with an access code.
You can set the access code with method protectWithAccessCode
which accepts an argument with the access code.
$magiclink = MagicLink::create(new DownloadFileAction('private_document.pdf'));
$magiclink->protectWithAccessCode('secret');
$urlToSend = $magiclink->url;
The access code is only as strong as the value you choose: 'secret' and '1234' above
are examples, not recommendations. Prefer a long, random code (the same kind of entropy
you'd use for a password), especially for anything protected with LoginAction.
Wrong access code guesses for a given magic link are throttled by default: after
max_attempts consecutive wrong guesses, the link is locked for decay_seconds, and
further attempts get a 429 response with a Retry-After header. This limit is keyed per
magic link, not per IP, so it can't be evaded by rotating IPs. A valid access-code cookie is
never throttled or counted, so a locked link never blocks its own already-verified visitor.
// config/magiclink.php
'access_code' => [
'max_attempts' => env('MAGICLINK_ACCESS_CODE_MAX_ATTEMPTS', 5),
'decay_seconds' => env('MAGICLINK_ACCESS_CODE_DECAY_SECONDS', 300),
],
Set max_attempts to 0 or 'none' to disable the limiter.
Note this limiter uses your application's default cache store. If that store isn't shared
across all the processes serving your app (for example, the array driver, which is
process-local), the limit won't be effective across processes.
You can customize the view of the access code form with the config file magiclink.php:
'access_code' => [
'view' => 'magiclink::access-code', // Change with your view
],
This is the default view
By default a link will be available for 72 hours after your creation. We can
modify the life time in minutes of the link by the $lifetime option
available in the create method. This argument accepts the value null so
that it does not expire in time.
$lifetime = 60; // 60 minutes
$magiclink = MagicLink::create(new ResponseAction(), $lifetime);
$urlToSend = $magiclink->url;
We also have another option $numMaxVisits, with which we can define the
number of times the link can be visited, null by default indicates that there
are no visit limits.
$lifetime = null; // not expired in the time
$numMaxVisits = 1; // Only can visit one time
$magiclink = MagicLink::create(new ResponseAction(), $lifetime, $numMaxVisits);
$urlToSend = $magiclink->url;
MagicLink can fires four events:
Event MagicLink\Events\MagicLinkWasCreated
This event is fired when a magic link is created.
Event MagicLink\Events\MagicLinkWasVisited
This event is fired when a magic link is visited.
Event MagicLink\Events\MagicLinkWasDeleted
This event is fired when an expired magic link is deleted individually. To enable it,
you need to enable deletion on creation in your .env file:
# Enable deletion of expired magic links when creating new ones
MAGICLINK_DELETE_EXPIRED_WHEN_CREATED=true
Event MagicLink\Events\MagicLinkAccessCodeFailed
This event is fired when a wrong access code is submitted for a link protected with
protectWithAccessCode. Use it to log or alert on access-code guessing attempts.
To custom this package you can publish the config file:
php artisan vendor:publish --provider="MagicLink\MagicLinkServiceProvider" --tag="config"
And edit the file config/magiclink.php
By default, creating a new MagicLink does not delete expired magic links from the database.
You can enable that cleanup by setting the delete_expired_when_created configuration to true:
// config/magiclink.php
'delete_expired_when_created' => env('MAGICLINK_DELETE_EXPIRED_WHEN_CREATED', false),
Or in your .env file:
MAGICLINK_DELETE_EXPIRED_WHEN_CREATED=true
[!TIP] Whether or not you enable cleanup on creation, consider using Laravel's Model Pruning to periodically clean up expired links.
If you set disable_default_route to true, you're responsible for registering your own
route, guarded by MagicLink\Middlewares\MagiclinkMiddleware. That middleware already
validates the token (expiration, visit limits, access code) and stores the resolved
MagicLink on the request, under MagiclinkMiddleware::REQUEST_ATTRIBUTE. Read it from
there instead of looking the token up again yourself, otherwise you'd lose those checks.
use Illuminate\Http\Request;
use MagicLink\MagicLink;
use MagicLink\Middlewares\MagiclinkMiddleware;
Route::get('my-magiclink/{token}', function (Request $request) {
$magicLink = $request->attributes->get(MagiclinkMiddleware::REQUEST_ATTRIBUTE);
abort_unless($magicLink instanceof MagicLink, 403);
return $magicLink->run();
})->middleware(MagiclinkMiddleware::class);
[!WARNING] If your route (or a custom/extended
MagiclinkMiddleware) doesn't set that request attribute,MagicLinkController::access()currently falls back to looking the token up itself — but that fallback is deprecated and will be removed in 3.0. See the CHANGELOG for details.
To customize the migration files of this package you need to publish the migration files:
php artisan vendor:publish --provider="MagicLink\MagicLinkServiceProvider" --tag="migrations"
You'll find the published files in database/migrations/*
When the magicLink is invalid by default the http request return a status 403.
You can custom this response with config magiclink.invalid_response.
To return a response, use class MagicLink\Responses\Response::class
same response(), you can send the arguments with options
Example:
'invalid_response' => [
'class' => MagicLink\Responses\Response::class,
'options' => [
'content' => 'forbidden',
'status' => 403,
],
],
To return a exception and let the framework handle the response,
use class MagicLink\Responses\AbortResponse::class.
Same abort(), you can send the arguments with options.
Example:
'invalid_response' => [
'class' => MagicLink\Responses\AbortResponse::class,
'options' => [
'message' => 'You Shall Not Pass!',
'status' => 403,
],
],
Define class MagicLink\Responses\RedirectResponse::class to
return a redirect()
'invalid_response' => [
'class' => MagicLink\Responses\RedirectResponse::class,
'options' => [
'to' => '/not_valid_path',
'status' => 301,
],
],
Define class MagicLink\Responses\ViewResponse::class to
return a view()
'invalid_response' => [
'class' => MagicLink\Responses\ViewResponse::class,
'options' => [
'view' => 'invalid',
'data' => [],
],
],
You can limit the number of requests per minute made to the magic link route. To do this,
you need to set the MAGICLINK_RATE_LIMIT environment variable to the desired value.
By default, the rate limit is disable with value 'none', but you can set a value to limit the requests. For example, to limit the requests to 100 per minute, set
# .env
MAGICLINK_RATE_LIMIT=100
Note that this limit is keyed by IP address and domain (Laravel's default throttle signature), not by magic link: it protects the route as a whole, not an individual link from a determined attacker who can rotate IPs. To limit access-code guesses on a single link, see Access code attempt limiting.
MagicLink uses Laravel's Model Pruning feature through the MassPrunable trait to automatically clean up expired links.
You can prune expired MagicLinks by running the following Artisan command:
php artisan model:prune MagicLink\\MagicLink
You can also enable automatic cleanup of expired MagicLinks every time a new one is created by setting MAGICLINK_DELETE_EXPIRED_WHEN_CREATED to true in the env file:
# .env
MAGICLINK_DELETE_EXPIRED_WHEN_CREATED=true
If you have magic links created with a version prior to 2.24.2 (see the CHANGELOG), their actions are stored in a legacy serialized format and need to be migrated.
To migrate legacy serialized actions to the new format, run:
php artisan magiclink:migrate --dry-run
This command will simulate the migration and display the number of legacy MagicLinks found.
If everything looks good, run the migration for real with:
php artisan magiclink:migrate
Run the tests with:
composer test
Please see CONTRIBUTING for details.
The MIT License (MIT). Please see License File for more information.