The two flows

Login codes

The user submits their email, receives a short code, and types it into a verify form. Useful whenever the sign-in device is not the device reading the mail.


The flow

StepRouteWhat happens
1GET /auth/codeShow the email form.
2POST /auth/codeResolve the user, generate a code, email it, redirect to the verify form with the email held in the session.
3GET /auth/code/verifyShow the code entry form.
4POST /auth/code/verifyVerify the code, log the user in, redirect.

Sending one

use Torqie\LaravelPasswordless\Facades\LaravelPasswordless;

$code = LaravelPasswordless::for($user)->sendLoginCode();

Generates the code, notifies the user, fires LoginCodeSent, and returns the plain-text code. The return value exists for tests and for delivering over a channel other than email — do not log it.

Or link at the built-in form:

<a href="{{ route('passwordless.login-code.request') }}">Sign in with a code</a>

Code format

Six numeric digits by default:

'code' => [
    'length'  => 6,
    'charset' => '0123456789',
],

Make them longer, or alphanumeric:

PASSWORDLESS_CODE_LENGTH=8
PASSWORDLESS_CODE_CHARSET=ABCDEFGHJKLMNPQRSTUVWXYZ23456789

Drop the ambiguous characters

The charset above omits I, O, 0, and 1 on purpose. A code is read off a screen and typed by hand, and those four account for most misreads. If you go alphanumeric, take the same precaution — and note that the charset is used verbatim, so a character appearing twice is simply twice as likely.


Only one code is live at a time

Generating a code for a user revokes any of their existing valid codes and deletes their spent ones. So:

  • A user who requests a second code can only use the second one.
  • The table holds roughly one code row per user, not one per login attempt ever made.

Other users' rows are never touched.


How a code is verified

Codes are stored as sha256($salt . $code) with a per-row random salt. Because the salt differs per row, verification cannot look the code up by hash. Instead it loads that user's valid code rows and compares each with hash_equals, in constant time, so the comparison cannot be timed to leak a partial match.

A code that verifies is deleted, not marked used. See why.

Upgrading from before 2.2.0

Codes issued before the salt column existed carry a null salt and still verify against the original unsalted digest, so an upgrade does not lock anyone out mid-login. You do need to run php artisan migrate. See Upgrading.


Failed attempts are throttled

Five failed verifications per email address, by default, over one TTL window. Exceeding it raises a ValidationException with a countdown message on the code field. A successful verification clears the counter.

'rate_limits' => [
    'verify' => 5,
],
PASSWORDLESS_RATE_LIMIT_VERIFY=5

An unknown email address counts against the limit exactly as a wrong code does, and produces the same message — The code is incorrect or has expired — so the verify form does not reveal which addresses have accounts.


Customising the email

The notification renders laravel-passwordless::emails.login-code. Override it:

'views' => [
    'login_code_email' => 'emails.auth.login-code',
],

Your view receives:

VariableTypeDescription
$codestringThe plain-text code
$expiresMinsintTTL in minutes

The email on the verify form

POST /auth/code stashes the submitted address in the session and redirects to the verify form, so the user does not retype it. Under Inertia, the login_code_verify component receives it as an email prop.

Previous
Magic links
Next
Routes