NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

The package ships opt-in JSON endpoints per guard. Register them with routes.enabled = true for the guard, or explicitly:

// routes/api.php
use RoundlyConsulting\Auth\Facades\Authentication;

Authentication::routes('users');                                      // defaults

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

RouteRegistrar offers prefix(), name(), middleware(), authenticatedMiddleware(), only() and except(); it registers on destruct. Defaults: prefix {guard}/auth, names authentication.{guard}.*, middleware api. Registering a guard twice throws, and so does registering one on an effective prefix or route-name prefix another guard already took (after prefix() / name(), from routes.enabled or a manual call alike) — Laravel would keep only the last route per URL; the message names both guards. Groups for only() / except(): login, challenge, tokens, registration, invitations, passwords, email, account, sessions, two-factor, passkeys, invitations.manage.

Endpoints

Every route carries authentication.guard:{guard} and Cache-Control: no-store, and exists only when its feature is enabled for the guard (a disabled feature is a 404). Tokens always travel in request bodies (JSON). * = authenticated (auth:{laravel_guard}); ? = optional.

MethodURI (under the prefix)BodyPurpose
POSTloginidentifier, passwordPassword login — identifier is any of identifier.columns (the email by default); there is no email field.
POSTlogin/magic-linkemailRequest a magic link.
POSTlogin/magic-link/consumetokenSign in with the link’s token.
POSTlogin/otpemailRequest an email code.
POSTlogin/otp/verifyemail, codeSign in with the code.
POSTlogin/passkey/options—Passwordless passkey options.
POSTlogin/passkeycredentialPasswordless passkey login.
POSTchallenge/two-factorchallenge_token, code, method? (totp | recovery_code)TOTP or recovery code (either kind is accepted under totp).
POSTchallenge/two-factor/enrolchallenge_tokenStart the forced TOTP enrolment (secret, QR code, recovery codes).
POSTchallenge/two-factor/enrol/confirmchallenge_token, codeConfirm it.
POSTchallenge/passkey/optionschallenge_tokenPasskey second-factor options.
POSTchallenge/passkeychallenge_token, credentialPasskey second factor.
POSTchallenge/passkey/enrol/optionschallenge_tokenForced passkey enrolment options.
POSTchallenge/passkey/enrolchallenge_token, credential, name?Forced passkey enrolment.
POSTrefreshrefresh_tokenRotate the refresh token (the previous access token stops working).
POSTregisteremail, password (when required), attributes?Registration (attributes = the host fields of registration.rules).
POSTinvitations/previewtokenWhat an invitation is for.
POSTinvitations/accepttoken, password (when required), email? (only when lock_email is off), attributes?Accept an invitation.
POSTpassword/forgotemailRequest a reset link.
POSTpassword/resettoken, passwordReset the password.
POSTemail/verifytoken, email (with the code channel)Verify an address — token is the link token or the code.
POSTemail/verification/resendemailResend verification (guest).
POSTemail/change/confirmtokenConfirm an email change (opened from the mail).
GET*me—The account.
PATCH*localelocale?, timezone?Locale / timezone.
POST*reauthenticatemethod + password | code | credentialRe-authentication — method is password, totp, recovery_code, email_otp or passkey.
POST*reauthenticate/passkey/options, reauthenticate/otp—Passkey options / mail a re-authentication code.
GET*activityquery per_page?Own login activity.
POST*logout, logout/others, logout/everywhere—Logouts.
GET* / DELETE*sessions, sessions/{session}—Device sessions.
PUT*passwordcurrent_password (when the account has one), passwordChange password.
POST*email/changeemailRequest an email change.
POST*email/verification—Send verification.
GET* POST* DELETE*two-factor, two-factor/recovery-codes—Status, start enrolment, disable, regenerate recovery codes.
POST*two-factor/confirmcodeConfirm the enrolment.
GET* POST*passkeys, passkeys/optionspasskeys: credential, name?List, registration options, register.
PATCH* / DELETE*passkeys/{passkey}PATCH: nameRename / remove.
GET*invitationsquery status?, per_page?Invitation admin (Gate ability).
POST*invitationsemail, payload?, locale?, ttl? (seconds), send? (default true)Create — 201 with data and the link as url (shown once; with send: false nothing is mailed and you deliver it).
POST* / DELETE*invitations/{invitation}/resend, invitations/{invitation}—Resend ({"status": "sent", "url": …}) / revoke.

Every endpoint that signs in also takes device_name? (shown in the session list); register and invitations/accept also take timezone? (stored on the new account). The device is read from the X-Device-Id header (activity.new_device.header), the locale from X-Locale / Accept-Language. credential is the browser’s PublicKeyCredential JSON with base64url members, plus the ceremonyId of the options it answers.

Responses

// 200 — authenticated
{ "status": "authenticated", "token_type": "Bearer", "access_token": "eyJ…", "expires_in": 900,
  "expires_at": "2026-09-26T10:15:00Z", "refresh_token": "…", "refresh_expires_at": "2026-10-26T10:00:00Z",
  "session_id": "0199…" }

// 200 — challenge
{ "status": "challenge", "challenge_token": "…", "expires_at": "…", "attempts_left": 5, "method": "password",
  "completed": [], "remaining": [ { "step": "second_factor", "methods": ["totp", "recovery_code", "passkey"] } ] }

// 202 — enumeration-safe acknowledgement (magic link, email code, forgot, resend, email change)
{ "status": "sent" }

// error
{ "message": "…", "code": "invalid_credentials", "errors": { "identifier": ["…"] } }

Credential-change responses (PUT password, POST two-factor/confirm, DELETE two-factor, POST two-factor/recovery-codes, POST passkeys, DELETE passkeys/{passkey}) include tokens or null. When non-null the client must swap to it immediately — its previous access token died with the change.

Errors

CodeStatusWhen
invalid_credentials422Wrong credentials (uniform).
invalid_token / invalid_code422An emailed link or code is invalid (uniform).
challenge_invalid / factor_failed / factor_not_allowed422Challenge problems (attempts_left on factor_failed).
invalid_invitation422An invitation is invalid (uniform).
invitation_send_limit422An invitation was mailed max_sends times — final (no Retry-After): revoke it and create a new one.
passkey_registration_failed422A passkey did not register.
refresh_invalid401The refresh token is invalid (uniform).
account_disabled / email_not_verified / enrolment_required / reauthentication_required403Account state or policy (methods on reauth).
registration_closed / invitation_required / login_denied403Registration or risk.
account_locked423A re-authenticating account is locked.
too_many_attempts429Throttled, or a hard-locked account on a link, code or passkey login (Retry-After).
two_factor_required / last_credential / two_factor_already_enabled / two_factor_not_enabled409Refused changes.
method_disabled / not_found404Feature off or unknown id.
a validation error on password422Breached-password service down with fail_closed.
misconfigured500Configuration error (detail only with app.debug).

Changing the error response

Every package exception renders itself — {message, code, errors?, …extra} with Cache-Control: no-store — and no global handler is touched. Laravel’s handler (the same in Laravel 12 and 13) first applies the $exceptions->map() mappers, then lets an exception with its own render() answer, and only then runs the $exceptions->render() / renderable() callbacks. So a render() callback for a package exception never runs. Two hooks do work:

use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use RoundlyConsulting\Auth\Exceptions\AuthenticationMisconfigured;
use RoundlyConsulting\Auth\Exceptions\AuthException;
use Symfony\Component\HttpFoundation\Response;

->withExceptions(function (Exceptions $exceptions): void {
    // Reshape the rendered response; the status, Cache-Control and Retry-After stay:
    $exceptions->respond(function (Response $response, Throwable $e, Request $request): Response {
        if ($e instanceof AuthException) {
            $response->setContent((string) json_encode(['error' => ['code' => $e->errorCode(), 'message' => $e->getMessage()]]));
        }

        return $response;
    });

    // Or replace the exception before Laravel renders it (misconfiguration left alone):
    $exceptions->map(AuthException::class, fn (AuthException $e): Throwable =>
        $e instanceof AuthenticationMisconfigured ? $e : new ApiProblem($e));
});

The host exception carries over what the package’s render() would have sent:

use Illuminate\Http\JsonResponse;
use RoundlyConsulting\Auth\Exceptions\AuthException;

final class ApiProblem extends RuntimeException
{
    public function __construct(public readonly AuthException $auth)
    {
        parent::__construct($auth->getMessage(), 0, $auth);
    }

    public function render(): JsonResponse
    {
        $extra = $this->auth->extra();   // retry_after, attempts_left, methods
        $headers = ['Content-Type' => 'application/problem+json', 'Cache-Control' => 'no-store, private', 'Pragma' => 'no-cache'];

        if (isset($extra['retry_after'])) {
            $headers['Retry-After'] = (string) $extra['retry_after'];   // 423 and 429
        }

        return new JsonResponse(
            ['type' => $this->auth->errorCode(), 'title' => $this->getMessage(), 'status' => $this->auth->status(), ...$extra],
            $this->auth->status(),
            $headers,
        );
    }

    public function report(): bool
    {
        return true;   // map() also runs when Laravel reports: stay unreported, like the package exception
    }
}
  • respond() runs on every rendered response, the self-rendered ones included, and receives $response, $e and $request. It sees responses that are not the package’s too, so branch on $e.
  • map() also applies when Laravel reports the exception, so the mapped one is what gets reported. Keep it unreported (report() returning true, or list it in dontReport), or your error tracker fills with failed logins.
  • With map() you render everything yourself: the status (status()), the extra members (extra(): retry_after, attempts_left, methods), field() if clients read errors, and the headers — Cache-Control: no-store, private always, Retry-After on 423 and 429.
  • Leave AuthenticationMisconfigured (and GuardNotConfigured) unmapped: its message names the broken config key, the package’s render() shows it only with app.debug, and it is the one exception the package wants reported.
  • Map to an exception without a render() method of its own and your render() callbacks see that exception — the package exception is its getPrevious().

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.