---
title: "HTTP API — Auth for Laravel | Roundly"
description: "Opt-in JSON endpoints per guard — registration, route groups, every endpoint with its body, response shapes, error codes and how to reshape them."
url: https://roundly-consulting.com/open-source/docs/auth-for-laravel/http-api
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Auth for Laravel](https://roundly-consulting.com/open-source/docs/auth-for-laravel.md)

# HTTP API

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

```php
// 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.

| Method | URI (under the prefix) | Body | Purpose |
| --- | --- | --- | --- |
| POST | `login` | `identifier, password` | Password login — identifier is any of identifier.columns (the email by default); there is no email field. |
| POST | `login/magic-link` | `email` | Request a magic link. |
| POST | `login/magic-link/consume` | `token` | Sign in with the link’s token. |
| POST | `login/otp` | `email` | Request an email code. |
| POST | `login/otp/verify` | `email, code` | Sign in with the code. |
| POST | `login/passkey/options` | — | Passwordless passkey options. |
| POST | `login/passkey` | `credential` | Passwordless passkey login. |
| POST | `challenge/two-factor` | `challenge_token, code, method? (totp \| recovery_code)` | TOTP or recovery code (either kind is accepted under totp). |
| POST | `challenge/two-factor/enrol` | `challenge_token` | Start the forced TOTP enrolment (secret, QR code, recovery codes). |
| POST | `challenge/two-factor/enrol/confirm` | `challenge_token, code` | Confirm it. |
| POST | `challenge/passkey/options` | `challenge_token` | Passkey second-factor options. |
| POST | `challenge/passkey` | `challenge_token, credential` | Passkey second factor. |
| POST | `challenge/passkey/enrol/options` | `challenge_token` | Forced passkey enrolment options. |
| POST | `challenge/passkey/enrol` | `challenge_token, credential, name?` | Forced passkey enrolment. |
| POST | `refresh` | `refresh_token` | Rotate the refresh token (the previous access token stops working). |
| POST | `register` | `email, password (when required), attributes?` | Registration (attributes = the host fields of registration.rules). |
| POST | `invitations/preview` | `token` | What an invitation is for. |
| POST | `invitations/accept` | `token, password (when required), email? (only when lock_email is off), attributes?` | Accept an invitation. |
| POST | `password/forgot` | `email` | Request a reset link. |
| POST | `password/reset` | `token, password` | Reset the password. |
| POST | `email/verify` | `token, email (with the code channel)` | Verify an address — token is the link token or the code. |
| POST | `email/verification/resend` | `email` | Resend verification (guest). |
| POST | `email/change/confirm` | `token` | Confirm an email change (opened from the mail). |
| GET\* | `me` | — | The account. |
| PATCH\* | `locale` | `locale?, timezone?` | Locale / timezone. |
| POST\* | `reauthenticate` | `method + password \| code \| credential` | Re-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\* | `activity` | query per\_page? | Own login activity. |
| POST\* | `logout, logout/others, logout/everywhere` | — | Logouts. |
| GET\* / DELETE\* | `sessions, sessions/{session}` | — | Device sessions. |
| PUT\* | `password` | `current_password (when the account has one), password` | Change password. |
| POST\* | `email/change` | `email` | Request 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/confirm` | `code` | Confirm the enrolment. |
| GET\* POST\* | `passkeys, passkeys/options` | passkeys: credential, name? | List, registration options, register. |
| PATCH\* / DELETE\* | `passkeys/{passkey}` | PATCH: name | Rename / remove. |
| GET\* | `invitations` | query status?, per\_page? | Invitation admin (Gate ability). |
| POST\* | `invitations` | `email, 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

```json
// 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

| Code | Status | When |
| --- | --- | --- |
| `invalid_credentials` | 422 | Wrong credentials (uniform). |
| `invalid_token / invalid_code` | 422 | An emailed link or code is invalid (uniform). |
| `challenge_invalid / factor_failed / factor_not_allowed` | 422 | Challenge problems (attempts\_left on factor\_failed). |
| `invalid_invitation` | 422 | An invitation is invalid (uniform). |
| `invitation_send_limit` | 422 | An invitation was mailed max\_sends times — final (no Retry-After): revoke it and create a new one. |
| `passkey_registration_failed` | 422 | A passkey did not register. |
| `refresh_invalid` | 401 | The refresh token is invalid (uniform). |
| `account_disabled / email_not_verified / enrolment_required / reauthentication_required` | 403 | Account state or policy (methods on reauth). |
| `registration_closed / invitation_required / login_denied` | 403 | Registration or risk. |
| `account_locked` | 423 | A re-authenticating account is locked. |
| `too_many_attempts` | 429 | Throttled, 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_enabled` | 409 | Refused changes. |
| `method_disabled / not_found` | 404 | Feature off or unknown id. |
| a validation error on password | 422 | Breached-password service down with fail\_closed. |
| `misconfigured` | 500 | Configuration 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:

```php
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:

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/auth-for-laravel.md)
