DEV Community

Andrej Mihaliak
Andrej Mihaliak

Posted on AI-assisted

Auth for Laravel — headless multi-guard login with 2FA, passkeys and JWT sessions

Every API-backed Laravel app we built needed sign-in, and the hand-written version kept having the same small gaps: a login error that told you whether an email was registered, a password reset that left old sessions alive, a two-factor step a client could skip. Each one is minor. Together they are how accounts get taken over. So we pulled all of it into one package and open-sourced it.

What it does

Auth for Laravel is headless account authentication for Laravel apps behind a mobile app or a single-page frontend. It owns the policy and orchestration; your app keeps its screens.

  • Four ways to sign in: password, magic link, email code and passkey.
  • A challenge engine for the second step (authenticator code, backup code or passkey), including forced enrolment when a guard requires two-factor.
  • RS256 access tokens with rotating refresh tokens, and device sessions a user can end one by one, all others, or everywhere.
  • Registration, invitations, email verification, email change and password resets.
  • A login-activity log, throttling, new-device alerts and a hook for your own risk rules.
  • Separate guards (users, clients, staff), each with its own model, config, endpoints, JWT audience and sessions.
  • Opt-in JSON endpoints and an event for every state change.

Installation

composer require roundly-consulting/auth-for-laravel
php artisan jwt:generate-keys
php artisan authentication:install   # publishes config + migrations, prints the guard wiring
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

authentication:install prints the config/auth.php guard wiring instead of editing your config, and php artisan authentication:check tells you what is still wrong.

Usage

Give the guard's model the contracts of the features it uses:

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use RoundlyConsulting\Auth\Concerns\HasAuthentication;
use RoundlyConsulting\Auth\Contracts\Account;
use RoundlyConsulting\Passkeys\Concerns\InteractsWithPasskeys;
use RoundlyConsulting\Passkeys\Contracts\HasPasskeys;
use RoundlyConsulting\RefreshTokens\Traits\HasRefreshTokens;
use RoundlyConsulting\TwoFactor\Concerns\HasTwoFactorAuthentication;
use RoundlyConsulting\TwoFactor\Contracts\TwoFactorAuthenticatable;

class User extends Authenticatable implements Account, HasPasskeys, TwoFactorAuthenticatable
{
    use HasAuthentication, HasRefreshTokens, HasTwoFactorAuthentication, InteractsWithPasskeys, Notifiable;

    protected function casts(): array
    {
        return [...$this->authenticationCasts(), ...$this->twoFactorCasts(), 'email_verified_at' => 'datetime'];
    }
}
Enter fullscreen mode Exit fullscreen mode

Log in — the result is a token pair, or a challenge when a second factor is due:

use RoundlyConsulting\Auth\DataTransferObjects\PasswordCredentials;
use RoundlyConsulting\Auth\Facades\Authentication;
use RoundlyConsulting\Auth\Http\Resources\ChallengeResource;
use RoundlyConsulting\Auth\Http\Resources\TokenPairResource;

$guard = Authentication::guard('users');

$result = $guard->attempt(
    new PasswordCredentials(identifier: $request->string('email')->toString(), password: $request->string('password')->toString()),
    $guard->contextFrom($request),
);

return $result->isAuthenticated()
    ? TokenPairResource::make($result->tokens)
    : ChallengeResource::make($result->challenge);   // continue with $guard->challenges()->complete(…)
Enter fullscreen mode Exit fullscreen mode

Rotate the refresh token, or sign the account out of every device:

$tokens = $guard->refresh($refreshToken, $guard->contextFrom($request));   // the old access token stops working

$guard->logoutEverywhere($user);
Enter fullscreen mode Exit fullscreen mode

Finishing the second step

When a login returns a challenge, the client sends the code back and you complete it on the same guard. Backup codes and passkeys go through the same complete() call.

use RoundlyConsulting\Auth\DataTransferObjects\ChallengeFactorData;
use RoundlyConsulting\Auth\Enums\FactorMethod;

$context = $guard->contextFrom($request);

if ($result->requiresChallenge()) {
    $pending = $result->challenge;   // token, expiresAt, method, completed, remaining (ChallengeRequirement[]), attemptsLeft
    // … later, with the user's TOTP code:
    $result = $guard->challenges()->complete(new ChallengeFactorData(
        challengeToken: $pending->token,
        method: FactorMethod::Totp,
        context: $context,
        code: $request->input('code'),
    ));
}
Enter fullscreen mode Exit fullscreen mode

Challenges are single-use and stored in the database, and each code attempt is counted before the code is checked, so parallel guesses get no extra tries.

Endpoints, per guard

The JSON endpoints are off until you switch them on, either with routes.enabled or explicitly:

// routes/api.php
Authentication::routes('users');                                      // defaults

Authentication::routes('clients')
    ->prefix('api/clients/auth')
    ->name('clients.auth.')
    ->middleware(['api'])
    ->authenticatedMiddleware(['authentication.active'])
    ->except(['registration', 'invitations.manage']);
Enter fullscreen mode Exit fullscreen mode

A route exists only while its feature is enabled for that guard.

More than one kind of account

Scaffold a guard (model, migration, factory), then check it:

php artisan authentication:guard staff --model=StaffMember --no-passkeys
php artisan authentication:check staff
Enter fullscreen mode Exit fullscreen mode

Guards share nothing: a users token on a clients route is a 401 even for the same primary key. Two-factor, passkeys, registration mode (open, invite-only or closed), verification and password rules are all set per guard.

Defaults we cared about

  • Password login, magic-link and email-code requests, and forgot password all answer the same way whether or not the address is registered.
  • Throttles take the attempt before the password is hashed, so concurrent guesses are all counted.
  • Emailed links and codes, invitations and challenges are single-use and stored only as keyed HMAC hashes.
  • By default a password, email or two-factor change ends the other sessions; the device that made the change gets a fresh token pair back.
  • Reusing a rotated refresh token kills that session and alerts the owner.

Testing

There is no Authentication::fake(), on purpose: the InteractsWithAuthentication helpers issue a real token pair, so tests hit the real guard, audience, denylist and token-version checks:

$this->actingAsAccount($user, 'users', [AuthMethodReference::Pwd]);   // a REAL token pair as the bearer
$this->assertLoginActivity('users', ActivityType::PasswordLogin, ActivityOutcome::Succeeded);
$this->assertTokensInvalidated($user, InvalidationReason::PasswordChanged);   // moved since actingAsAccount() (or pass `since:`)
Enter fullscreen mode Exit fullscreen mode

Every write also fires an event, so Event::fake() covers the rest.

Social login and SSO

Neither ships in the package, but your existing SSO callback can vouch for the user and get the same token pair as any other login:

// SSO callback: the host vouches for the authentication
$pair = Authentication::guard('users')->issueTokens($user, $context, LoginMethod::Host, [AuthMethodReference::Mfa]);
Enter fullscreen mode Exit fullscreen mode

Requirements

PHP ^8.4 (ext-bcmath, ext-mbstring, ext-openssl), Laravel 12 or 13, a cache store with atomic locks and a mail transport. MIT licensed. Runtime dependencies are only Laravel, Symfony and other Roundly packages, which Composer pulls in.

Links

Auth for Laravel is part of Roundly's open-source set of Laravel packages; the registry above lists all of them, written so you can hand it straight to your coding agent.

Top comments (1)

Collapse
 
elijahbrown profile image
Elijah Brown •

Ending the other sessions on an email change is a good default. If it isn't in there already, the companion I'd want is a notice to the old address with a short-lived link to undo the change, since changing the email is often the first move after an account takeover and the old inbox is where the real owner will still see it. It would also help to state in the docs how addresses are compared for uniqueness on registration and email change, because whether Jane@ and jane@ end up as one account or two depends on that.