Search by

harrisonclewis / laravel-passwordless

HarrisonCode

Passwordless authentication for Laravel

Package info

github.com/harrisonclewis/laravel-passwordless

pkg:composer/harrisonclewis/laravel-passwordless

Statistics

Installs: 310

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v0.4.0 2026-09-10 02:47 UTC

This package is auto-updated.

Last update: 2026-09-10 03:22:19 UTC


README

Laravel Passwordless

Introduction

Passwordless authentication for Laravel. No passwords, no hassle. Users enter their email and receive either a one-time login link or a one-time code to type in — your choice of one mode, configured with passwordless.mode.

Installation

Migrate an existing app

If you are replacing an existing Laravel auth flow, use the included PROMPT.md with your AI coding agent. It gives the agent package-specific instructions for installing this package, updating your auth UI, removing old password routes, preserving redirects and middleware, and verifying the magic-link flow.

Copy the contents of PROMPT.md into your AI coding agent.

Install Package

composer require harrisonclewis/laravel-passwordless

Run migrations:

php artisan migrate

Optional — publish the configuration and views:

php artisan vendor:publish --tag=passwordless-config
php artisan vendor:publish --tag=passwordless-views

Usage

Sending an authentication link

<form method="POST" action="{{ route('passwordless.store') }}">
    @csrf
    <input type="email" name="email" placeholder="you@example.com" />
    <label><input type="checkbox" name="remember" value="1"> Remember me</label>
    <button type="submit">Login</button>
</form>

@if ($sent = session(config('passwordless.flashes.sent')))
    <p>Check your email at {{ $sent['email'] }} for a login link.</p>
@endif

@if ($error = session(config('passwordless.flashes.error')))
    <p>{{ $error['message'] }}</p>
@endif

For Inertia apps, the same payloads arrive as flash props under the configured keys — passwordless, passwordless:authenticated, and passwordless:error by default.

Flash payloads

Config key Default Shape
flashes.sent passwordless `['sent' => true, 'email' => '…', 'mode' => 'link
flashes.authenticated passwordless:authenticated ['authenticated' => true, 'email' => '…']
flashes.error passwordless:error ['error' => true, 'reason' => '…', 'message' => '…']

reason is one of expired, consumed, user_not_found, invalid_code, or too_many_attempts.

Routes

Method URI Description
POST /passwordless Accepts an email, creates and sends the magic link or code
POST /passwordless/verify Accepts an email and one-time code, authenticates the user
POST /passwordless/resend Sends again, on a slower limit — the "didn't get the email?" button
GET /passwordless/{token} Consumes a link token and authenticates the user
POST /passwordless/{token} Target of the confirmation screen, when enabled

Point your login form at route('passwordless.store') and the rest is handled for you.

One-time codes

Instead of a magic link, the email can carry a short numeric code the user types into your login screen — useful where clicking links in email is awkward (kiosk logins, second devices) or actively hostile (aggressive corporate link scanners).

// config/passwordless.php
'mode' => 'code', // or PASSWORDLESS_MODE=code

Exactly one mode is active at a time — mode is a single value, so links and codes can never both be issued. Tokens carry the mode they were issued under, which means switching modes doesn't break emails already in flight; but a link secret can never be redeemed as a code, nor a code as a link, because the two are digested differently in storage.

The flow becomes: the same passwordless.store form sends the code, then your login page swaps to a code input that POSTs email and code to route('passwordless.verify'). The sent flash carries the mode and the email, so the follow-up form builds itself:

@if ($sent = session(config('passwordless.flashes.sent')))
    @if ($sent['mode'] === 'code')
        <p>Enter the code we emailed to {{ $sent['email'] }}.</p>

        <form method="POST" action="{{ route('passwordless.verify') }}">
            @csrf
            <input type="hidden" name="email" value="{{ $sent['email'] }}">
            <input
                type="text"
                name="code"
                inputmode="numeric"
                autocomplete="one-time-code"
                required
                autofocus
            >
            <button type="submit">Sign in</button>
        </form>
    @else
        <p>Check your email at {{ $sent['email'] }} for a login link.</p>
    @endif
@endif

A failed code redirects back with the error flash (reason of invalid_code, expired, consumed, or too_many_attempts), so render session(config('passwordless.flashes.error')) next to the code input.

A code is short enough to type, which makes it short enough to guess, so three things box that in:

  • routes.throttle.verify (default 6,1) rate limits the entry endpoint as tightly as issuing.
  • code.max_attempts (default 5) wrong guesses kill the code — after that even the correct code is refused and a new one must be requested.
  • Only the newest code per address counts: requesting another code retires the previous one, so an attacker can't widen the target by requesting many.

code.length (default 6 digits) sets the size; lengthen the code before you ever loosen the attempts. Everything else — registration, remember, redirects, queueing, pruning — behaves exactly as in link mode, and codes live in the same login_tokens table.

The code email is its own template. Publish views and edit resources/views/vendor/passwordless/mail/code.blade.php to rebrand it.

Rate limits

Issuing and consuming are limited separately, because they are different risks:

// config/passwordless.php
'routes' => [
    'middleware' => ['web'],
    'throttle' => [
        'store' => '6,1',    // issuing a link mails a stranger's address
        'consume' => '30,1', // consuming one already requires the secret
        'verify' => '6,1',   // a short code is guessable, so entry stays tight
        'resend' => '1,1',   // asking to mail the same address again
    ],
],

The consumption limit is looser on purpose. A single office behind one egress IP can easily produce more than six link-clicks a minute, and a limit sized for the mail-sending endpoint would lock them out. Set any value to null to remove the limit, or to the name of a limiter you have registered.

Each of these counts in a bucket of its own. That is worth knowing because Laravel does not do it for you: a bare throttle:6,1 is keyed on the domain and the caller's IP, not on the route, so every route carrying one shares a single tally. The package appends a key prefix to keep them independent — otherwise one login request would spend part of the resend allowance, and a run of wrong codes would spend the issuing allowance.

A named limiter is passed through untouched, because Laravel only recognises one when it is the sole throttle argument. Reach for one when a plain per-IP count is the wrong key — resend is the usual case, since one person on an office network would otherwise hold the button for everyone behind it:

// app/Providers/AppServiceProvider.php
RateLimiter::for('passwordless-resend', fn (Request $request) => [
    Limit::perMinute(1)->by('address:'.Str::lower((string) $request->input('email'))),
    Limit::perMinute(6)->by('caller:'.$request->ip()),
]);
'resend' => 'passwordless-resend',

Registration

By default, users who don't have an account are created automatically when they consume a link. Disable this to restrict login to existing users:

// config/passwordless.php
'register' => false,

With registration off, an unknown address still gets the same "check your email" response — it just never receives mail — so the endpoint can't be used to discover which addresses have accounts. That holds all the way through: a code typed against an address with no account spends the same guess budget and returns the same errors, right down to too_many_attempts.

The cost is a dead end: somebody who has never signed up is sent to a screen telling them to check an inbox that will never receive anything. If that matters for your install, keep the entry point off the public pages so people reach it deliberately rather than by trying to sign up.

Package-created users are written with name, email, an unusable random password, and email_verified_at set. If your users table differs, bind your own implementation:

$this->app->bind(
    \Harlew\Passwordless\Contracts\CreatesNewUser::class,
    \App\Auth\CreateNewUser::class,
);

Failed links

Expired and already-used links redirect to passwordless.error_redirect (default: your login route) with the reason in the error flash, so your own login page renders the message.

If that config value doesn't resolve to a real route or URL, the package aborts instead: 410 for an expired or already-used link, 404 for a link with no account behind it.

Confirmation screen

Corporate mail security — Outlook SafeLinks, Microsoft Defender, most scanning gateways — fetches every link in an incoming email. Against a plain GET that consumes the token, the scanner burns the link and your user sees "already used" on their first real click.

// config/passwordless.php
'confirm' => true,

With this on, the link renders a "Sign in" button that POSTs instead. Scanners follow the GET and stop there, so the token survives until a human clicks.

It's off by default because it costs an extra click and consumer mail providers don't prefetch this way. Turn it on if your users are on business email.

Queueing the link

By default the login link is sent on the request that asked for it, which puts an SMTP round-trip in front of the "check your email" screen. Hand it to a queue worker instead:

// config/passwordless.php
'queue' => ['enabled' => true],

Or PASSWORDLESS_QUEUE=true.

This is off by default on purpose — turning it on makes the login email depend on a worker being up, and if no worker is running, nobody can log in. Make sure queue:work is actually running before you enable it.

connection and queue are null by default, meaning the application defaults. tries (default 3) and backoff (default '10,30') are read off the notification, so they take precedence over the worker's --tries and --backoff. Retrying is safe because a second email for the same token carries the same single-use URL rather than a new one.

backoff is the seconds to wait before each retry — '10,30' waits 10s, then 30s, then keeps using 30s. It also accepts a single number or an array:

'queue' => [
    'backoff' => '10,30',  // or 30, or [10, 30, 60], or null
],

It isn't null by default because a retry budget without one is close to useless: the attempts fire back to back, which is no help against the transient provider rate limits and brief outages the retries exist for. Set it to null to defer to the worker.

PasswordlessNotification is the class that reaches the mailer in both modes, so tests asserting on it keep working whichever way it is configured.

Pruning

Tokens are kept past expiry so support can still explain a failed link, then deleted. Schedule Laravel's pruning command:

use Illuminate\Support\Facades\Schedule;

Schedule::command('model:prune', [
    '--model' => [\Harlew\Passwordless\Models\Token::class],
])->daily();

The retention window is prune_after_days (default 7).

How tokens are stored

The secret in the magic link is 64 characters of CSPRNG output. Only its SHA-256 is written to the database — the secret itself is never stored, so a database dump, read replica, or backup can't be turned into a login for anyone.

One-time codes live in the same table and are also stored only as a digest, salted with the token's id. The salt is what keeps two users who draw the same 6 digits from colliding, defeats a precomputed table of every possible code, and makes the modes mutually exclusive at the database — neither digest scheme can ever match a secret issued under the other.

That has one consequence worth knowing: $token->url() only works on the instance that generated the token. A token read back from the database can't rebuild its own link.

Consumption is a conditional UPDATE ... WHERE consumed_at IS NULL, so two requests racing for the same link — a scanner and a click, or a double-tap — can't both win.

Configuration

// config/passwordless.php
return [
    'mode'             => 'link',    // 'link' or 'code' — exactly one is active
    'code'             => [...],     // One-time code length and attempt budget
    'redirect'         => '/',       // Where to send the user after login
    'error_redirect'   => 'login',   // Where to send expired/used links
    'register'         => true,      // Auto-create users for unknown emails
    'token_lifetime'   => 900,       // Token expiry in seconds (default: 15 min)
    'confirm'          => false,     // Interstitial that defeats link prefetching
    'queue'            => [...],     // Send the link from a queue worker
    'prune_after_days' => 7,         // Retention for consumed/expired tokens

    ... others
];

Extension points

Every step is a contract bound in the container. Rebind any of them:

Contract Default Responsibility
CreatesToken Actions\CreateToken Issue a token
SendsToken Actions\SendToken Deliver the link
ConsumesToken Actions\ConsumeToken Claim the token and log the user in
VerifiesCode Actions\VerifyCode Check an email/code pair and log the user in
CreatesNewUser Actions\CreateNewUser Create an account for an unknown email

Requirements

  • PHP ^8.1
  • Laravel ^10.0|^11.0|^12.0|^13.0

Testing

composer test

License

MIT