# Authentication · Martis docs

> Martis provides a complete authentication system, from login and logout to two-factor authentication (2FA), magic links and a user profile page. Every part, browser-session management included, is configurable and overridable.

Source: https://getmartis.com/docs/auth/authentication

# Authentication

Martis provides a complete authentication system, from login and logout to two-factor authentication (2FA), magic links and a user profile page. Every part, browser-session management included, is configurable and overridable.

> See also: [SSO](https://getmartis.com/docs/auth/sso) for OAuth/OIDC providers (Azure / Google / GitHub / custom), and [Impersonation](https://getmartis.com/docs/auth/impersonation) for the login-as-another-user subsystem (admins surfacing a switch from the user menu and from the User Resource detail page).

## Login Flow

Martis uses Laravel's authentication guards. By default, it uses the application's default guard.

### Login Page

The login page is accessible at `/{martis-path}/login`. It renders a form with email and password fields, styled to match the current theme.

**API Endpoint:**

```
POST /martis/api/auth/login
Content-Type: application/json
 
{
    "email": "admin@example.com",
    "password": "secret",
    "keep_signed_in": true
}
```

`keep_signed_in` is optional (defaults to `false` when omitted). When `true`, the login issues Laravel's long-lived remember-me cookie (`remember_web_*`) so the session survives past `config('session.lifetime')`; the login page renders the "Keep me signed in on this device" toggle checked by default. Both the SPA endpoint above and the non-SPA `POST /{martis-path}/login` share the same handling (v1.31.1+).

**Responses:**

| Status | Body | Description |
| --- | --- | --- |
| `200` | `{ "id": 1, "name": "Maria", "email": "...", "avatar_url": "...", ... }` | Successful login. The user object is returned flat (no `user` wrapper). Authentication is session-cookie based, so there is no `token` to track. |
| `200` | `{ "two_factor_required": true, "message": "..." }` | 2FA enabled on the account — the frontend redirects to the challenge screen. |
| `200` | `{ "email_verification_required": true, "message": "..." }` | (v1.8.14+) Email verification is enabled (`MARTIS_AUTH_EMAIL_VERIFICATION_ENABLED=true`) and the user has not confirmed yet. The session is still established so the resend-link endpoint behind `auth:` works — the frontend redirects to `/{martis-path}/email/verify` instead of the dashboard. The same gate appears on `GET /api/auth/user` as `email_verification_pending: true` so a refresh / deep-link reload bootstraps on the verify page rather than the SPA shell. |
| `200` | `{ "password_change_required": true, "message": "..." }` | (v2.3.0) The [forced password change](https://getmartis.com/docs/auth/authentication#forced-password-change) holds the user: the session is established and the frontend goes to the change page. `GET /api/auth/user` answers `password_change_pending: true` for the same user, so a reload lands on the page too. |
| `422` | `{ "message": "...", "errors": {...} }` | Validation error. |
| `429` | `{ "message": "Too many attempts" }` | Rate limited. Default: `MARTIS_LOGIN_THROTTLE_ATTEMPTS=20` per `MARTIS_LOGIN_THROTTLE_MINUTES=1`. The same envelope applies to register, password-reset and 2FA challenge endpoints. Per-email throttle in addition to per-IP — see [Per-email throttle](https://getmartis.com/docs/auth/authentication#per-email-throttle) below. |

> **Auth model.** Martis is **session-cookie based**, not bearer-token based. The Laravel guard sets the cookie on `attempt()` + `session()->regenerate()`. There is no JWT; no `Authorization: Bearer …` header is needed (or accepted) by any Martis endpoint. Same-origin SPA calls work out of the box.

### Per-email throttle

Besides the per-IP `throttle:N,1` envelope (configured via `MARTIS_LOGIN_THROTTLE_ATTEMPTS` / `_MINUTES`), both password sign-in routes (`POST /{martis-path}/login` since v2.4.0, and `POST /api/auth/login`) and the magic-link request run a second named limiter, `martis-login`, that holds one limit for a request that names an email (lowercased and trimmed): the email **and the client IP**, `MARTIS_LOGIN_THROTTLE_ATTEMPTS` (20) per `MARTIS_LOGIN_THROTTLE_MINUTES` (1). It stops one machine guessing at one account, without one IP's attempts counting against another's.

On its own that limit gives every source IP a bucket of its own for the same email, so an attacker spreading guesses over N addresses got N x 20 guesses a minute at one account. The **per-account limit** (v2.4.0) closes that: `MARTIS_LOGIN_THROTTLE_EMAIL_ATTEMPTS` (100) per `MARTIS_LOGIN_THROTTLE_EMAIL_MINUTES` (15), the same bucket whatever the source IP. It is a higher threshold over a longer window, so a user who mistypes a password never meets it, and it is applied by the sign-in controllers (`Martis\Auth\AccountLoginThrottle`), not by the named limiter:

- **Only wrong passwords count.** The controller refuses with `429` (before it checks the password, the right one included) when the bucket is used up, counts a wrong password, and clears the bucket on a right one. The owner's own good sign-ins never spend it.
- **One bucket per account, not per spelling.** It is keyed on the id of the user the email matches in the Martis guard's provider (a case- or accent-insensitive database collation matches `Victim@x` and `victím@x` to one row), falling back to the lowercased, trimmed email when no user matches.
- **The magic-link request has a bucket of its own** (same size): it counts its own requests, known address or not, so password noise cannot starve it, its requests cannot starve the password sign-in, and one address cannot be mailed without bound.

The cost of any per-account limit is that someone who sends that many wrong passwords for an email can keep its owner from signing in for the rest of the window; raise the threshold, or set `MARTIS_LOGIN_THROTTLE_EMAIL_ATTEMPTS=0` to turn the per-account limit off and keep the first. Up to v2.3.0 `POST /{martis-path}/login` carried the per-IP throttle only and the `martis-login` limiter had no per-account limit at all.

The named limiter is registered in `MartisServiceProvider::registerRateLimiters()` and reads its config per request. Empty-payload requests (no `email` field at all) fall back to per-IP keying so an unfocused script still hits the rate limit. Override the default by re-registering `RateLimiter::for('martis-login', ...)` in your own service provider — last definition wins.

### Logout

```
POST /martis/api/auth/logout
```

Invalidates the server-side session. The browser cookie is cleared on the next request.

### Check Current User

```
GET /martis/api/auth/user
```

Public route (deliberately unprotected) so the Login page can probe the active session without a noisy `401` in the console. Returns the user object when a session cookie is present, or `null` when the visitor is a guest.

The user object, here and in the login response, is the user model's attributes without the password, the remember token and the 2FA secret and recovery codes, plus the avatar the Topbar shows, from the profile resource (see [Custom Profile Resource](https://getmartis.com/docs/auth/authentication#custom-profile-resource)): `avatar_url`, and the `avatar_initials` and `avatar_palette` (a slot of the theme's `--martis-avatar-N` tokens) it falls back to without a picture.

## Auth UI shell

All unauthenticated pages (Login, Register, 2FA challenge, 404 / 403 / 500) share a single shell component:

```
components/auth/AuthFrame.tsx        — dot-grid background, brand row, card slot, Shell footer
components/auth/AuthControls.tsx     — guest-mode theme + language pickers (top-right)
components/auth/ErrorScreen.tsx      — watermark code + accent icon + CTA buttons
```

`AuthFrame` is the only shell; each page (`pages/Login.tsx`, `pages/Register.tsx`, `pages/TwoFactorChallenge.tsx`, `pages/NotFound.tsx`, `pages/Forbidden.tsx`, `pages/ServerError.tsx`) renders its own content inside.

### CSS surface

The auth CSS lives in `resources/css/martis.css` and uses the namespaced `.martis-auth-*` family:

| Class | Role |
| --- | --- |
| `.martis-auth-frame` | Full-viewport wrapper with the accent halo background. |
| `.martis-auth-bg` | Dot-grid overlay with a radial mask. |
| `.martis-auth-card` | Surface card (400 px default) that holds the form. |
| `.martis-auth-brand` | Logo row at the top of the card. No text label — the logo asset already carries the wordmark. |
| `.martis-auth-title` / `.martis-auth-sub` | Primary title and muted subtitle. |
| `.martis-auth-divider` | "or" rule between SSO buttons and email/password. |
| `.martis-auth-foot` | Centered footer matching the Shell footer (`© {brand} · Powered by Martis`). |
| `.martis-auth-controls` | Top-right theme / language toggle strip. |
| `.martis-auth-back` | Circular back button (used on the 2FA challenge). |
| `.martis-auth-forgot` | Inline "Forgot?" link. |
| `.martis-auth-toggle` / `.martis-auth-toggle-track` | Custom toggle switch for the "Keep me signed in" option. |
| `.martis-auth-otp-row` / `.martis-auth-otp-input` | 6-cell OTP grid for the 2FA challenge. |
| `.martis-error-screen` / `.martis-error-code` / `.martis-error-icon` / `.martis-error-title` / `.martis-error-desc` / `.martis-error-id` | Error screens (404 / 403 / 500) with watermark code and optional incident id. |

### Brand logo

`AuthFrame` reads the logo from `@images/logo.png` shipped with the package. Consumers that rebuild the frontend can swap the import with their own asset. The brand row renders **only the image** — do not add a text wordmark: the logo already contains "Martis".

## Alternative sign-in flows

Martis ships themed pages and default backend handlers for **all four** guest auth surfaces. Every surface is independently togglable, every page can point off-platform, and every backend handler can be swapped for a consumer's own implementation.

The four surfaces:

| Surface | Frontend page | Backend endpoint | Default handler |
| --- | --- | --- | --- |
| Login | `pages/Login.tsx` | `POST /{martis-path}/api/auth/login` | `LoginController` (on by default; email and password sign-in can be switched off, see [Password sign-in switch](https://getmartis.com/docs/auth/authentication#password-sign-in-switch-v2210)) |
| Register | `pages/Register.tsx` | `POST /{martis-path}/api/auth/register` | `Martis\Auth\DefaultRegistersUsers` |
| Forgot password | `pages/ForgotPassword.tsx` | `POST /{martis-path}/api/auth/password/email` | `Martis\Auth\DefaultSendsPasswordResetLinks` |
| Reset password | `pages/ResetPassword.tsx` | `POST /{martis-path}/api/auth/password/reset` | `Martis\Auth\DefaultResetsUserPasswords` |

### config/martis.php → auth

```
'auth' => [
    'sso' => [
        'enabled'   => env('MARTIS_SSO_ENABLED', false),
        'providers' => [
            // Per-provider blocks (azure, google, github, …) live here.
            // See sso.md for the full schema and `martis:sso` scaffolder.
        ],
    ],
    'passwordReset' => [
        'enabled' => env('MARTIS_AUTH_PASSWORD_RESET_ENABLED', false),
        // Empty → Martis serves /forgot-password and /reset-password.
        // Set    → "Forgot?" link redirects off-platform.
        'url'     => env('MARTIS_AUTH_PASSWORD_RESET_URL'),
        // Laravel password broker name (config/auth.php → passwords.*).
        // Unset: the broker whose provider is the Martis guard's.
        'broker'  => env('MARTIS_AUTH_PASSWORD_BROKER'),
    ],
    'registration' => [
        'enabled'      => env('MARTIS_AUTH_REGISTRATION_ENABLED', false),
        // Empty → Martis serves /register; Set → off-platform link.
        'url'          => env('MARTIS_AUTH_REGISTRATION_URL'),
        // Optional role to assign to every new user (Spatie/HasRoles).
        'default_role' => env('MARTIS_AUTH_REGISTRATION_DEFAULT_ROLE'),
    ],
    'controls' => [
        'theme'  => env('MARTIS_AUTH_CONTROL_THEME', true),
        'locale' => env('MARTIS_AUTH_CONTROL_LOCALE', true),
    ],
    // v2.21.0: see "Password sign-in switch" and "Challenge (captcha) hook" below.
    'password_login' => [
        'enabled'            => env('MARTIS_PASSWORD_LOGIN_ENABLED', true),
        'break_glass_emails' => [], // env: MARTIS_PASSWORD_LOGIN_BREAK_GLASS_EMAILS, comma-separated
    ],
    'challenge' => [
        'enabled'    => env('MARTIS_AUTH_CHALLENGE_ENABLED', false),
        'verifier'   => env('MARTIS_AUTH_CHALLENGE_VERIFIER'),
        'site_key'   => env('MARTIS_AUTH_CHALLENGE_SITE_KEY'),
        'applies_to' => ['login', 'forgot', 'magic_link', 'register'],
    ],
],
```

All the flows above default to `false`. A fresh `composer require martis/martis` install ships only the Login surface enabled (email and password sign-in is on unless you set `password_login.enabled` to `false`) — no placeholder CTAs, no orphan endpoints.

| Block | Shape | Purpose |
| --- | --- | --- |
| `sso` | `enabled` + `providers` map | Renders one button per enabled provider. Martis owns the OAuth dance end-to-end. See [`sso.md`](https://getmartis.com/docs/auth/sso). |
| `passwordReset` | `enabled` + `url` + `broker` | Renders the "Forgot?" link, hosts `/forgot-password` and `/reset-password` pages, and POST endpoints. `url` redirects off-platform. `broker` selects the Laravel password broker; unset, Martis picks the one whose provider is the Martis guard's (see [Which password broker resets a password](https://getmartis.com/docs/auth/authentication#which-password-broker-resets-a-password)). |
| `registration` | `enabled` + `url` + `default_role` | Renders the "Create an account" link, hosts `/register` page and POST endpoint. `default_role` is auto-assigned via `assignRole()` when set. |
| `controls` | `theme` + `locale` | Top-right widget visibility on every auth surface. |
| `password_login` | `enabled` + `break_glass_emails` | Switches email and password sign-in off for an SSO-only panel (v2.21.0). See [Password sign-in switch](https://getmartis.com/docs/auth/authentication#password-sign-in-switch-v2210). |
| `challenge` | `enabled` + `verifier` + `site_key` + `applies_to` | A captcha hook on the public forms (v2.21.0). See [Challenge (captcha) hook](https://getmartis.com/docs/auth/authentication#challenge-captcha-hook-v2210). |

### Surface lifecycle (each flow)

1. **Disabled** (`enabled=false`): page route 302s to `/login`; link stays hidden; POST endpoints return 404 (invitations: 503). This answer comes first: with the [challenge](https://getmartis.com/docs/auth/authentication#challenge-captcha-hook-v2210) listing the surface, the verifier is not called for a flow that is switched off, so there is no 422 and no call to the captcha vendor.
2. **On-platform** (`enabled=true`, `url=''`): Martis renders its own themed page; POST endpoints route through the Martis-shipped default handler.
3. **Off-platform** (`enabled=true`, `url='https://…'`): the link in the Login page points off-platform; the internal page redirects there too; POST endpoints stay at 404 (because the consumer hosts their own elsewhere).

### Forgot password

When `auth.passwordReset.enabled=true` and `url` is empty:

1. Login page renders the "Forgot?" link next to the password field.
2. Click → router pushes `/forgot-password`. The page asks for the email and POSTs to `/api/auth/password/email`.
3. Server calls `Password::broker(<broker>)->sendResetLink()`, which dispatches a notification through whichever mailer the host app has configured (Resend, Mailgun, SES, SMTP).
4. Email arrives with a link to `/reset-password#token=...&email=...` (the token is in the URL fragment, see [One-time links keep the token out of the request line](https://getmartis.com/docs/auth/authentication#one-time-links-keep-the-token-out-of-the-request-line)). The page asks for the new password and POSTs the token, the email and the password to `/api/auth/password/reset`.
5. Server calls `Password::broker(<broker>)->reset()`, fires `Illuminate\Auth\Events\PasswordReset`, and returns 200.
6. Client toasts success and redirects to `/login`.

**The answer does not say whether the address has an account (v2.4.0).** Both endpoints are public, so what they say must not let an anonymous caller confirm which addresses are panel accounts:

- `POST /api/auth/password/email` answers `200 { "ok": true, "status": "We have emailed your password reset link." }` for every address the request validates for: a known one, an unknown one, and a known one asked again within the broker's `throttle` window (the broker answers `passwords.throttled` there, which only a known address gets). The response is the same, byte for byte, and only a known address is mailed. An SSO-only account (no password hash) answers the same way. The magic-link request has always worked like this.
- `POST /api/auth/password/reset` answers `422 "This password reset token is invalid."` for an unknown address, as it does for a known one with a wrong token (the broker says `passwords.user` for the first and `passwords.token` for the second).
- With `APP_DEBUG=true` both endpoints give the broker's detailed `422` (`passwords.user`, `passwords.throttled`), which helps while setting reset up. Up to v2.3.0 production answered `422` with that detail too, although the controller's docblock said it was neutral there.

Request validation errors (a malformed address) stay `422` for every caller. Two differences remain that a mailer makes: a mail failure (the SMTP timeout above) answers `503`, and so only for an address that has an account, and sending takes longer than not sending, so a caller who times the endpoint can tell a known address from an unknown one. Send the reset notification on a queue (a `ShouldQueue` notification, see Laravel's notification docs) to remove both.

### Which password broker resets a password

The forgot-password and reset endpoints run on a Laravel password broker (`config/auth.php` → `passwords`), which finds the account by email through its provider. Martis uses the broker of the Martis guard's users (`Martis\Auth\GuardCatalog::martisPasswordBroker()`):

- `MARTIS_AUTH_PASSWORD_BROKER` unset: the app's default broker (`auth.defaults.passwords`) when its provider is the Martis guard's provider, else the first broker whose provider is. A default install (`web` guard, `users` provider, `users` broker) resolves `users`.
- `MARTIS_AUTH_PASSWORD_BROKER` set: that broker, which must exist and whose provider must be the Martis guard's (or sign in users of the same table).

Anything else throws a `Martis\Auth\PasswordBrokerConfigurationException` (an `InvalidArgumentException`) naming `martis.auth.passwordReset.broker`, and the endpoint answers 500 with it reported: a broker of another provider would look the email up among other users (the site's `users` beside an `admins` guard) and mail or reset that account. For an `admins` guard, declare its broker:

```
// config/auth.php
'passwords' => [
    'users' => ['provider' => 'users', 'table' => 'password_reset_tokens', 'expire' => 60, 'throttle' => 60],
    'admins' => ['provider' => 'admins', 'table' => 'password_reset_tokens', 'expire' => 60, 'throttle' => 60],
],
```

Nova reads one broker too (`NOVA_PASSWORDS`, `config('nova.passwords')`, the app's default broker when null) and does not check its provider against `NOVA_GUARD`, which [nova-issues#1989](https://github.com/laravel/nova-issues/issues/1989) reports as resetting the wrong users. Martis picks the broker from the guard and refuses a mismatch instead (v2.0.1).

## Registration

When `auth.registration.enabled=true` and `url` is empty:

1. Login page renders the "Create an account" link under the Sign in button.
2. Click → router pushes `/register`. The page asks for `name`, `email`, `password`, `password_confirmation` and POSTs to `/api/auth/register`.
3. Server resolves the bound `RegistersUsers` implementation (default: `DefaultRegistersUsers`), validates, creates the user, optionally assigns `auth.registration.default_role`, fires `Illuminate\Auth\Events\Registered`, and returns 201.
4. Client toasts success and redirects to `/login`.

The `default_role` knob covers the most common SaaS flow ("every signup lands on the `free` plan"). Anything more elaborate (audit logging, locale defaults, payment provider customer creation, Stripe checkout redirect, invite-token validation) goes through the override hook.

## What reaches the browser (v2.21.0)

The login page is public, and the Blade shell hands the SPA its configuration as `window.MartisConfig`. Up to v2.20.x the `auth` entry of it was the whole `config('martis.auth')` array, so the SSO provider blocks (role maps, tenant ids, logout URLs), column names, throttle values and any key an app added to the block were readable by anyone who opened `/martis/login`.

Since v2.21.0 `window.MartisConfig.auth` is an allow-list, built by `Martis\Auth\PublishedAuthConfig::toArray()` key by key. A key that is not listed below is not sent, at any level, and an unknown key is never passed through:

| Key | Published | Not published (stopped in v2.21.0) |
| --- | --- | --- |
| `sso` | `enabled`, and for each **enabled** provider only `enabled`, `label` (a string or a `locale => string` map, v2.22.0), `icon` plus the keys of its `public` array (see [SSO](https://getmartis.com/docs/auth/sso#what-reaches-the-browser-v2210)). No provider while `sso.enabled` is `false`. | Disabled providers; every other key of a provider block (`driver`, `scopes`, `role_*`, `tenant`, `resource_id`, `logout_url`, `redirect_to`, `remember`, …). |
| `passwordReset` | `enabled` (off while password sign-in is), `url` | `broker` |
| `registration` | `enabled`, `url` | `default_role` |
| `controls` | `theme`, `locale` |  |
| `magicLink` | `enabled` | `ttl_minutes` / `ttlMinutes`, `auto_register` |
| `emailVerification` | `enabled` | `notice_url` |
| `passwordRequirements` | the app's password policy |  |
| `passwordChange` | `enabled`, `url` | `column` |
| `passwordLogin` | `enabled`, `breakGlass` (v2.23.0: a boolean, true when password sign-in is off and `break_glass_emails` holds an address) | `break_glass_emails` itself |
| `challenge` | `enabled`, `siteKey`, `appliesTo` | `verifier` |
| `copy` | the whole `copy` block, verbatim: it is presentation text by design |  |

`window.MartisConfig.flash` (v2.21.0) is the one flashed message of the request, built by `Martis\Auth\PublishedFlash`: `{ "sso": "<text>" | null }`. A refused SSO sign-in redirects to the login page with the `sso` error of the session flash, and the shell publishes that string (never the rest of the error bag, which also holds field errors and anything an app flashed) so the login page can show it as an error toast on arrival, also on an SSO-only page. It is read from the session flash without consuming it, so it is on that one page load and a reload shows nothing. See [SSO](https://getmartis.com/docs/auth/sso#failed-sign-ins-reason-log-and-event-v2210).

Only `auth` is an allow-list. The other blocks the Blade shell publishes in `window.MartisConfig` (`toast`, `theme`, `keyboardShortcuts`, `userMenu`, `search`, `dashboard`, `welcome`, `footer`, `layout`, `loader`, `notifications`, `stickyViews`, `extensions`, `dev`, `resourceRecordUrls` and the like) are sent whole, as before: any key an app adds to one of those blocks of `config/martis.php` is readable by anyone who loads a page, so keep secrets out of them.

Everything else of `martis.auth` stays on the server: the old flat `google` block, the snake_case duplicates of the camelCase keys above (`magic_link`, `email_verification`, `password_change`), any key an app added, the throttle values. An app whose custom login component (`auth:login`) reads one of the keys above that is no longer published lists it in the `public` array of its SSO provider block, or reads it from its own endpoint.

## Password sign-in switch (v2.21.0)

For a panel whose people sign in through SSO only, turn email and password sign-in off:

```
'auth' => [
    'password_login' => [
        'enabled'            => env('MARTIS_PASSWORD_LOGIN_ENABLED', true),
        // Comma-separated in the env var; trimmed and compared case-insensitively.
        'break_glass_emails' => array_values(array_filter(array_map(
            'trim',
            explode(',', (string) env('MARTIS_PASSWORD_LOGIN_BREAK_GLASS_EMAILS', ''))
        ))),
    ],
],
```

With `enabled` false:

- `POST /{martis-path}/login` and `POST /{martis-path}/api/auth/login` answer **403** with the translated `password_login_disabled` message, the same answer for every address, **before** the controller runs: no credential is read, validated or checked, so the response says nothing about whether an account exists. The login throttles still run first.
- An address in `break_glass_emails` passes through to the normal sign-in (the way back in when the identity provider is down). Keep the list short: it is the one place a password still works. A break-glass sign-in goes through the same throttles and, when configured, the [challenge](https://getmartis.com/docs/auth/authentication#challenge-captcha-hook-v2210).
- **The list is not an oracle, as far as the response and the clock go.** A break-glass sign-in that does not succeed (no such account, a wrong password, a malformed body, the per-account throttle, a missing challenge token) gets exactly the 403 and body every other address gets, on both routes, and what the controller flashed to the session is dropped. Only a sign-in that succeeds, or the legitimate next step after correct credentials (the 2FA challenge, the email verification or password change pages), shows the controller's answer. Failed attempts still count against the per-account throttle. A request that is already signed in as the same account cannot tell it apart from a failure: sign out first. An address that is not listed is not refused at once: Martis first does the account lookup the sign-in does and one password check against a decoy hash made with your current hasher settings (driver and rounds, made once per process), and a listed address with no account pays the same check, so the time of the answer does not tell a listed address from an unlisted one. What cannot be equalised, and a determined caller can still measure: the call to your challenge verifier, which only a listed address reaches (the challenge runs after the switch and is skipped for an address that may not use the form), the state of the throttles, and the difference between the real check and the decoy on a very slow or noisy hasher. Treat the list as hard to enumerate, not as secret.
- **A malformed list fails where it is used.** The published `breakGlass` flag is computed without validating `break_glass_emails`, so a list that is not an array of strings never fails a page render (it reads as no list, or, for an array with a non-string entry, as a list). The break-glass sign-in reads the list strictly and throws an `AuthConfigurationException` naming `martis.auth.password_login.break_glass_emails`.
- The address is compared and looked up in the same form: trimmed and lower-cased. `Admin@X.com` signs in as the account `admin@x.com`, never as another one, so the account of a break-glass address must be stored in lower case (or on a case-insensitive collation). Only break-glass requests are rewritten; while password sign-in is on nothing changes.
- **A listed person cannot tell a typo from "not listed".** Every failure of a listed address is the same "turned off" 403, so a mistyped password, a used-up per-account throttle bucket and an address stored with capitals on a case-sensitive database (it never matches) all read as "you are not allowed". Store the break-glass account in lower case. The per-account bucket of that account can be used up by anyone who knows the address (the documented cost of a per-account limit, see `throttle.login_email_attempts`), and then the right password gets the same 403 until the window passes.
- **Password reset is off with it**: the forgot and reset endpoints answer as they do when `passwordReset.enabled` is `false` (404), their pages redirect to `/login`, and the SPA hides the "Forgot?" link. A reset link would hand a password sign-in back through the inbox. Break-glass addresses cannot reset either: an administrator sets their password.
- The login page shows the SSO buttons only: no divider, no email and password form, no "Forgot?" link, and the SSO-only subtitle (`login_sub_sso_only`, or `auth.copy.login.subtitle_sso_only` / `MARTIS_AUTH_LOGIN_SUBTITLE_SSO_ONLY`). `/martis/login?password_login=1` renders the form anyway, the entry point for a break-glass sign-in, but only when `break_glass_emails` holds at least one address (v2.23.0: the page reads the published boolean `passwordLogin.breakGlass`; with an empty list the parameter is ignored, since nobody could use the form). The page decides nothing about who may use it: the server answers 403 to any other address, and the break-glass list is never published, only whether it is empty. A refused SSO sign-in toasts its text here too. The subtitle is spread over its lines (`text-wrap: balance`), so it no longer breaks after the hyphen of "sign-in" in the 400 px card.

The other ways in keep their own switches: magic-link sign-in (`auth.magic_link.enabled`), invitations (`martis.invitations.enabled`) and registration (`auth.registration.enabled`). **An SSO-only panel turns them off** (`MARTIS_AUTH_MAGIC_LINK_ENABLED=false`, `MARTIS_AUTH_REGISTRATION_ENABLED=false`, invitations off), or they remain a password-equivalent way in. With the form hidden, a magic-link or registration control that is still on shows only its own button or link.

**Turning the switch off does not end the sessions that already exist.** It gates the two sign-in routes only. A session opened with a password while it was on, a remember-me cookie (five years by default), and a session opened by an invitation or a magic link all stay valid, and `auth.sso.max_session_minutes` and the revalidation only touch SSO sessions. A panel that moves to SSO-only because the identity provider should decide who gets in (leavers, revoked accounts) must end them itself: cycle the `remember_token` of the users (`User::query()->update(['remember_token' => Str::random(60)])`) and clear the sessions (truncate the `sessions` table of the database driver, or empty the file or Redis store the sessions live in). Everybody signs in again, through SSO.

If password sign-in is off and nothing else lets people in (no enabled SSO provider and magic link off), Martis logs one warning naming `martis.auth.password_login.enabled`, `martis.auth.sso` and `martis.auth.magic_link.enabled`, at most once a day (a cache key guards it, so this holds with a cache store that persists between requests; with the `array` store the warning is logged on every boot, and a failing cache drops it silently, never breaking the boot). Only the break-glass addresses could still sign in.

An empty `enabled` (a `MARTIS_PASSWORD_LOGIN_ENABLED=` line with no value, which `env()` reads as an empty string) means "unset": the default, on, applies, so a copied example line never locks everyone out. A value of `enabled` that is neither a boolean nor a boolean string, or a `break_glass_emails` that is not an array of strings, throws an `AuthConfigurationException` (an `InvalidArgumentException`) naming the key when a sign-in route or the login page reads it. The provider boot (every artisan command, queue worker and request) does not read it fatally: a bad value leaves the password reset URL builder unset instead of stopping the application, and the request path still throws.

## Landing page after sign-in (v2.21.0)

The SPA signs in with a JSON request. Up to v2.20.x it then opened the dashboard whatever Laravel's `url.intended` held; the form sign-in (`POST /login`) always redirected to it. Since v2.21.0 the response that completes a JSON sign-in carries `intended_url`, the page asked for before the visitor was sent to the login page, and the SPA opens it with a full page load. The signed-token issuer (`GET /{martis}/sso/issue/{audience}`, see [SSO](https://getmartis.com/docs/auth/sso#18-signed-token-between-two-martis-apps-v2210-v2220)) depends on it.

- The value is pulled from the session once, so it is used once.
- Only a same-origin URL whose path is under the Martis base path (`martis.path`) is returned, as a path and query without the fragment. An absolute foreign or protocol-relative URL, another path, a dot segment, an encoded slash or dot, a backslash or a control character is dropped and forgotten, and the SPA checks the value again before it navigates (same rule as the SSO re-authentication redirect). Anything dropped leaves today's behaviour: the dashboard.
- It survives the steps between the password and the panel. `POST /api/auth/login` leaves it in the session when the 2FA challenge, the email verification or the forced password change still holds the person, and the response that completes the sign-in carries it: the 2FA challenge (`POST /api/2fa/challenge`), the forced password change (`POST /api/auth/password/change`), and, for email verification, the redirect of the emailed link when it is opened in the browser that holds the session.
- The non-SPA `POST /login` is unchanged: it redirects with Laravel's `redirect()->intended()`.
- Magic-link, invitation and SSO sign-ins are unchanged (the SSO callback already redirects to the intended URL).

## Challenge (captcha) hook (v2.21.0)

Martis ships no captcha vendor, only the extension point. While `auth.challenge.enabled` is on, a POST to a listed public form is refused unless your `ChallengeVerifier` accepts the token the browser sent, and each listed form renders a component you register.

```
'auth' => [
    'challenge' => [
        'enabled'    => env('MARTIS_AUTH_CHALLENGE_ENABLED', false),
        // A class implementing Martis\Contracts\ChallengeVerifier, resolved from the container.
        'verifier'   => env('MARTIS_AUTH_CHALLENGE_VERIFIER'),
        // Public, sent to the browser component. The verifier class is never published.
        'site_key'   => env('MARTIS_AUTH_CHALLENGE_SITE_KEY'),
        // login, forgot, reset, magic_link, register, invitation
        'applies_to' => ['login', 'forgot', 'magic_link', 'register'],
    ],
],
```

| Surface | Route (the POST) |
| --- | --- |
| `login` | `POST /login` and `POST /api/auth/login` |
| `forgot` | `POST /api/auth/password/email` |
| `reset` | `POST /api/auth/password/reset` |
| `magic_link` | `POST /api/auth/magic-link/request` |
| `register` | `POST /api/auth/register` |
| `invitation` | `POST /api/invitations/accept` |

**Server.** The route middleware `martis.challenge:<surface>` runs, in this order on a sign-in route: the throttles, the password sign-in switch, then the challenge. It calls `verify(Request $request): bool` of your verifier; the token is in `$request->input('challenge_token')`. `false`, or **any throwable** (reported with `report()`), refuses with **422** and `errors.challenge_token` carrying the translated `challenge_failed` message, and the controller is never reached. It fails closed: **a verifier outage (the captcha vendor down or slow) refuses every covered request**, so keep the HTTP timeout short and watch the reported exceptions. A surface that is not in `applies_to` is untouched, and so is a surface whose own flow is switched off (registration, password reset, magic link, invitations): its controller answers its usual 404 or 503 and the verifier is not called, so a disabled flow costs no call to the captcha vendor.

With the challenge enabled, a `verifier` that is missing, names a class that does not exist or does not implement `Martis\Contracts\ChallengeVerifier`, or an `applies_to` that holds a name outside the six above, throws an `InvalidArgumentException` naming `martis.auth.challenge.verifier` or `martis.auth.challenge.applies_to`. It throws on the first covered request, so a misconfigured panel fails loudly instead of letting the form through. An empty `enabled` (`MARTIS_AUTH_CHALLENGE_ENABLED=` with no value) means "unset": off.

**Browser.** The SPA receives `window.MartisConfig.auth.challenge = { enabled, siteKey, appliesTo }`. Each listed form renders the component registered under the component-registry key `auth:challenge` (like `auth:login` and the other `auth:*` keys, there is no `martis:component` scaffold: register it from your extension bundle), sends the token it produced as `challenge_token` in the request body, and bumps `resetSignal` after a failed submit so a one-time token is renewed. The component receives:

| Prop | Type |  |
| --- | --- | --- |
| `siteKey` | `string \| null` | `auth.challenge.site_key` |
| `surface` | `string` | The form it sits on (`login`, `forgot`, …). On the login page one widget serves two actions, the password sign-in and the magic-link request, and a widget cannot know which button the person will press before it solves, so `surface` is the first of `login` and `magic_link` that is listed. |
| `onToken` | `(token: string \| null) => void` | Call with the token when the widget solves, `null` when it expires or errors. Stable between renders. |
| `resetSignal` | `number` | Changes after a failed submit: render a fresh challenge. |

If the challenge is on for a form and no `auth:challenge` component is registered, the form shows a visible configuration error and does not submit. Martis never patches `window.fetch`. An `auth:login` (or other auth page) override that replaces a bundled page keeps the challenge by reusing the wiring the bundled pages use, both exported by `@martis/runtime` (v2.21.0; republish the extension shims to import the names): `useAuthChallenge(surface)` (or an array of surfaces, as the login page passes) returns the state (`active`, `misconfigured`, `token`, `fields(surface)` for the request body, `reset()` to call after a submit, whether it failed or succeeded), and `<AuthChallenge challenge={challenge} error={errors.challenge_token} />` renders the registered widget, or a visible configuration error. Send `{ ...challenge.fields('login') }` with the request.

### Example: Cloudflare Turnstile

The verifier, `app/Martis/TurnstileVerifier.php`:

```
<?php
 
namespace App\Martis;
 
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Martis\Contracts\ChallengeVerifier;
 
class TurnstileVerifier implements ChallengeVerifier
{
    public function verify(Request $request): bool
    {
        $token = $request->input('challenge_token');
 
        if (! is_string($token) || $token === '') {
            return false;
        }
 
        // A short timeout, and throw() on an HTTP error: the middleware
        // treats any throwable as a refusal (fail closed), so an outage
        // of Cloudflare refuses the form rather than letting it through.
        $response = Http::asForm()
            ->timeout(3)
            ->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
                'secret' => config('services.turnstile.secret'),
                'response' => $token,
                'remoteip' => $request->ip(),
            ])
            ->throw();
 
        return $response->json('success') === true;
    }
}
```

The component, in your extension bundle (`resources/js/martis-extensions/overrides/TurnstileChallenge.tsx`). `AuthChallengeProps` is exported by `@martis/runtime` (republish the extension shims to get the type, see [Refreshing the extension scaffold](https://getmartis.com/docs/getting-started/installation#refreshing-the-extension-scaffold-after-an-upgrade)):

```
import { useEffect, useRef } from 'react'
import type { AuthChallengeProps } from '@martis/runtime'
 
interface TurnstileApi {
  render: (el: HTMLElement, options: Record<string, unknown>) => string
  reset: (id: string) => void
  remove: (id: string) => void
}
 
const turnstileWindow = window as unknown as { turnstile?: TurnstileApi }
 
let loading: Promise<TurnstileApi> | null = null
 
function loadTurnstile(): Promise<TurnstileApi> {
  loading ??= new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
    script.async = true
    script.onload = () => (turnstileWindow.turnstile ? resolve(turnstileWindow.turnstile) : reject(new Error('Turnstile did not load')))
    script.onerror = () => reject(new Error('Turnstile could not be loaded'))
    document.head.appendChild(script)
  })
  return loading
}
 
export default function TurnstileChallenge({ siteKey, surface, onToken, resetSignal }: AuthChallengeProps) {
  const box = useRef<HTMLDivElement>(null)
  const widget = useRef<string | null>(null)
 
  useEffect(() => {
    let cancelled = false
    loadTurnstile().then((turnstile) => {
      if (cancelled || !box.current || !siteKey) return
      widget.current = turnstile.render(box.current, {
        sitekey: siteKey,
        action: surface,
        callback: (token: string) => onToken(token),
        'expired-callback': () => onToken(null),
        'error-callback': () => onToken(null),
      })
    }).catch(() => onToken(null))
    return () => {
      cancelled = true
      if (widget.current !== null) turnstileWindow.turnstile?.remove(widget.current)
      widget.current = null
    }
  }, [siteKey, surface, onToken])
 
  // A token is good once: a failed submit asks for a new challenge.
  useEffect(() => {
    if (resetSignal > 0 && widget.current !== null) turnstileWindow.turnstile?.reset(widget.current)
  }, [resetSignal])
 
  return <div ref={box} />
}
```

Register it from the bundle entry (`resources/js/martis-extensions/index.ts`), then build the bundle:

```
import { componentRegistry } from '@martis/runtime'
import TurnstileChallenge from './overrides/TurnstileChallenge'
 
componentRegistry.register('auth:challenge', TurnstileChallenge)
```

Configure it:

```
// config/services.php
'turnstile' => ['secret' => env('TURNSTILE_SECRET')],
 
// config/martis.php
'auth' => [
    'challenge' => [
        'enabled'    => true,
        'verifier'   => App\Martis\TurnstileVerifier::class,
        'site_key'   => env('TURNSTILE_SITE_KEY'),
        'applies_to' => ['login', 'forgot', 'magic_link', 'register'],
    ],
],
```

A Content-Security-Policy must allow `https://challenges.cloudflare.com` for scripts and frames. Cloudflare's server-side answer also carries the `action` the widget was rendered with (here the surface). A verifier may compare it, with one caveat: the login page serves the password sign-in and the magic-link request with one widget, rendered with the surface `login`, so a verifier that compares `action` must accept `login` on the magic-link route too (or not compare it there). The example above does not check `hostname` or `action`, and `remoteip` is optional: behind a proxy Laravel does not trust, `$request->ip()` is the proxy's address, so leave it out unless your trusted proxies are configured.

## Customising auth surfaces

Martis exposes three independent override layers. Pick the one that matches the depth of the change you need.

### Layer 1 — point the link off-platform (config only)

Set `auth.{flow}.url` to the external URL. The "Sign up" / "Forgot?" link redirects there; the internal page redirects there too; POST endpoints become inert. Useful when the consumer hosts a marketing-grade signup on a different stack (Webflow, Next.js landing, etc.).

```
# .env
MARTIS_AUTH_REGISTRATION_ENABLED=true
MARTIS_AUTH_REGISTRATION_URL=https://app.example.com/signup
```

### Layer 2 — replace the React page (component override)

Use the Martis component override system to swap any of the auth pages. The artisan generator scaffolds a TSX file, registers it under a fixed key under `resources/js/martis-extensions/overrides/`, and the SPA router (`router.tsx`) resolves the override before the bundled default — exactly the same mechanism that already works for `--type=shell` / `--type=topbar` / etc.

```
php artisan martis:component LoginPage --type=login-page
```

Generates `resources/js/martis-extensions/overrides/LoginPage.tsx` (a working starting point that calls `useAuth().login()` and renders inside `AuthFrame`). The auth-page types always write this fixed file name, whatever name you pass, because the auto-discovery entry (`resources/js/martis-extensions/index.ts`) maps the file name to the registry key through its `OVERRIDE_KEYS` table (`LoginPage` → `auth:login`). No `register()` call is needed. If you register a component by hand instead, use the exact key: `componentRegistry.register('auth:login', MyLogin)`.

Same notation extends to every auth surface:

| `--type=` value | Registry key | Replaces |
| --- | --- | --- |
| `login-page` | `auth:login` | `pages/Login.tsx` |
| `register-page` | `auth:register` | `pages/Register.tsx` |
| `forgot-password-page` | `auth:forgot-password` | `pages/ForgotPassword.tsx` |
| `reset-password-page` | `auth:reset-password` | `pages/ResetPassword.tsx` |
| `email-verify-notice-page` | `auth:email-verify-notice` | `pages/EmailVerifyNotice.tsx` |
| `password-change-page` | `auth:password-change` | `pages/PasswordChangeRequired.tsx` |

After generating the override, build your extension bundle **in your application root** (never inside `vendor/martis/martis`, whose precompiled SPA does not include consumer code since v1.8.19):

```
npm run build:extensions
```

The bundle lands in `public/vendor/martis-user/extensions.js`, which the SPA loads at runtime from the URLs in `MARTIS_EXTENSIONS` (`martis:install` sets `/vendor/martis-user/extensions.js`). Visit `/{martis-path}/login` and the override renders instead of the bundled page. To check it, load the page itself: `window.Martis.componentRegistry.has('auth:login')` only shows that the key is registered. Before v2.2.0 the router read the key before the extension bundle loaded, so the bundled page rendered even with the key registered.

Reference impls live under `vendor/martis/martis/resources/js/pages/` — the stub starts as a working copy of the bundled default so you can edit incrementally rather than rewrite from scratch.

### Layer 3 — replace the backend handler (service container binding)

Bind your own implementation of the relevant contract in your service provider. The Martis-shipped controllers resolve the contract from the container, so the consumer's class transparently takes over.

```
// app/Providers/MartisServiceProvider.php
public function register(): void
{
    $this->app->bind(
        \Martis\Contracts\RegistersUsers::class,
        \App\Auth\MyRegistrar::class,
    );
 
    $this->app->bind(
        \Martis\Contracts\SendsPasswordResetLinks::class,
        \App\Auth\MyResetLinkSender::class,
    );
 
    $this->app->bind(
        \Martis\Contracts\ResetsUserPasswords::class,
        \App\Auth\MyPasswordResetter::class,
    );
}
```

Each contract has a single method and ships a battle-tested default. Override only what you need.

```
// app/Auth/MyRegistrar.php
namespace App\Auth;
 
use Illuminate\Auth\Events\Registered;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Martis\Contracts\RegistersUsers;
use App\Models\User;
 
class MyRegistrar implements RegistersUsers
{
    public function register(Request $request): Authenticatable
    {
        $data = $request->validate([
            'name'     => ['required', 'string', 'max:255'],
            'email'    => ['required', 'email', 'unique:users,email'],
            'password' => ['required', 'confirmed', 'min:12'],   // stricter than default
            'invite'   => ['required', 'exists:invitations,token'],
        ]);
 
        $user = User::create([
            'name'     => $data['name'],
            'email'    => $data['email'],
            'password' => Hash::make($data['password']),
            'plan'     => 'starter',                              // skip the default 'free'
        ]);
 
        $user->assignRole('starter');
        $user->markInvitationConsumed($data['invite']);
 
        event(new Registered($user));
 
        return $user;
    }
}
```

The Martis-shipped React form already understands the response shape (`201` on success, `422` with `errors.field` on validation failure), so the React side keeps working as long as the override returns an `Authenticatable` and throws `ValidationException` on invalid input.

> **Building an invite-token check like the `invite` field above?** Consider `martis:invitations` instead of hand-rolling it against self-service registration. It ships a dedicated `InvitationManager` (hashed single-use tokens, TTL, enumeration-neutral accept flow), a public accept screen, and a generator that scaffolds the admin-side resource + actions — a closed, invite-only onboarding flow that composes with (rather than bolts onto) the registration pipeline above. See [invitations.md](https://getmartis.com/docs/auth/invitations).

### Layer-by-layer compatibility

The three layers compose. Override the React page AND the backend AND keep the Martis-themed link visibility (Layer 1 staying empty). Or override only the React page and let the default backend keep working. Or do nothing and ship with the defaults.

The lemma we hold onto: **flexibility and personalization**. Every visible string, every business rule, and every page is replaceable; the boring infrastructure (route registration, throttle, CSRF, broker plumbing) stays out of the consumer's way.

### Guest controls visibility (auth.controls)

Two compact widgets live in the top-right of every auth surface (Login, Register, 2FA challenge, error pages): a theme cycle button (dark → light → system) and a language picker. Each one is independently togglable via `config/martis.php`:

```
'controls' => [
    'theme'  => env('MARTIS_AUTH_CONTROL_THEME', true),
    'locale' => env('MARTIS_AUTH_CONTROL_LOCALE', true),
],
```

| Toggle | Default | Effect when `false` |
| --- | --- | --- |
| `theme` | `true` | Hides the theme cycle button. Theme still resolves from `config/martis.theme.default`, the user's stored preference, or the system setting — only the visual control disappears. |
| `locale` | `true` | Hides the language picker. The active locale stays in effect from the per-user preference / `config/martis.locale` / `config/app.locale` — only the picker disappears. |

When both flags are `false`, the strip itself doesn't render, so a single-locale single-theme deployment gets a pristine login screen.

#### Guest persistence (v1.7.6)

A guest's choice on the strip persists in `localStorage` (`martis-preferences` key) and survives a hard refresh. The picker NEVER calls the server — `/api/preferences` is `martis.auth`-protected and would 401 for a guest, polluting the console. Once the user signs in, `readInitialPrefs` keeps their localStorage choice (priority over the server "default" payload) and the next explicit `update()` call writes it server-side.

**Whose picks they are (v2.4.0).** A guest's picks are carried to the account that signs in with one `PUT /api/preferences` only when they were made in the tab session of the person signing in: a marker (`martis-preferences-guest-modified`) is set in `sessionStorage` when a guest changes a preference, and consumed by the sign-in that follows in that tab (it survives the full page load a redirect brings). On a shared browser, picks a previous visitor left in `localStorage` never reach the next account: that sign-in reads the account's own saved preferences. Before v2.4.0 the marker was a `localStorage` flag, bound to no one; a leftover one is ignored and removed. Signing out also drops the cached preferences (`martis-preferences`) and the marker, so the login page that follows starts from the panel defaults.

Both controls render with the global PrimeReact tooltip (`data-pr-tooltip`), so styling matches the rest of the shell.

### Customising the auth copy

Two paths, in order of recommendation. Pick one:

#### Path 1 — Publish the language files (recommended for multi-locale)

This is the canonical Laravel path and what you want when:

- you ship multiple languages,
- you want translators to edit copy without touching PHP config,
- you also want to override other Martis strings (toasts, validation messages, dashboard greeting, …).

```
# In the consumer app, run once:
php artisan vendor:publish --tag=martis-lang --force
```

This copies the package's `resources/lang/{en,pt_BR,pt_PT}/*.php` to `resources/lang/vendor/martis/{en,pt_BR,pt_PT}/*.php` in your app. Edit only the keys you want to override — Laravel deep-merges the published file with the package shipped one, so unset keys fall through automatically.

The auth keys live in `auth.php`:

| Key | Renders on |
| --- | --- |
| `login_title` | Login page heading |
| `login_sub` | Login page subtitle (no SSO providers configured) |
| `login_sub_v2` | Login page subtitle (SSO providers visible) |
| `register_title` / `register_sub` | Register page |
| `forgot_password_title` / `forgot_password_sub` | Forgot-password page |
| `reset_password_title` / `reset_password_sub` | Reset-password page |
| `invitation_accept_title` / `invitation_accept_sub` | Invitation-accept page |

Example — `resources/lang/vendor/martis/pt_BR/auth.php`:

```
<?php
return [
    'login_title' => 'Entre no Acme',
    'login_sub' => 'Bem-vindo de volta. Use seu e-mail e senha.',
    // any key you don't override falls through to the package default.
];
```

After editing, `php artisan optimize:clear` (or restart php-fpm) for the new strings to land in production. Laravel resolves the active locale automatically; no Martis config involved.

#### Path 2 — auth.copy config override (single-locale or env-driven)

When the consumer runs a single language and wants a quick brand override without publishing the lang files, OR when the value has to come from env (CI / Docker injection), use `config('martis.auth.copy.*')`:

```
'auth' => [
    // ... sso / passwordReset / registration / controls
    'copy' => [
        'login' => [
            'title' => env('MARTIS_AUTH_LOGIN_TITLE'),
            'subtitle' => env('MARTIS_AUTH_LOGIN_SUBTITLE'),
            'subtitle_with_sso' => env('MARTIS_AUTH_LOGIN_SUBTITLE_SSO'),
        ],
        'register' => [
            'title' => env('MARTIS_AUTH_REGISTER_TITLE'),
            'subtitle' => env('MARTIS_AUTH_REGISTER_SUBTITLE'),
        ],
        'forgot_password' => [
            'title' => env('MARTIS_AUTH_FORGOT_TITLE'),
            'subtitle' => env('MARTIS_AUTH_FORGOT_SUBTITLE'),
        ],
        'reset_password' => [
            'title' => env('MARTIS_AUTH_RESET_TITLE'),
            'subtitle' => env('MARTIS_AUTH_RESET_SUBTITLE'),
        ],
        'invitation_accept' => [
            'title' => env('MARTIS_AUTH_INVITATION_ACCEPT_TITLE'),
            'subtitle' => env('MARTIS_AUTH_INVITATION_ACCEPT_SUBTITLE'),
        ],
    ],
],
```

Each entry accepts:

- **`null`** (default) — fall through to the bundled translation (or your published override from Path 1).
- **`string`** — applied verbatim on every locale.
- **`array<locale, string>`** — multi-locale (v1.8.5+). The React `useAuthCopy()` resolves the active locale at render time. **For multi-locale Path 1 is recommended** — keeping copy in lang files keeps the translation workflow standard. The array form exists for projects that prefer to keep all consumer customisation in `config/martis.php`.

#### Resolution order

When a page renders, the `useAuthCopy()` helper resolves each value in this order:

1. `config('martis.auth.copy.<page>.<key>')` — Path 2 override (string, array per locale, or null).
2. `__('martis::auth.<key>')` — published lang file (Path 1) **or** package default.

Path 1 always wins over the package defaults. Path 2 wins over both.

> **Recommendation**: when you have one customisation, use Path 2 (one line in `.env` or `config/martis.php`). When you have many, or you ship more than one language, use Path 1 (publish + edit).

### Password-reset URL routing (v1.8.3)

Laravel's bundled `ResetPassword` notification renders the email link via `route('password.reset', ...)`. Martis nests every route under a `martis.` name prefix, so the global `password.reset` is undefined and the broker would crash with `RouteNotFoundException`. Starting with v1.8.3, `MartisServiceProvider::boot()` registers `ResetPassword::createUrlUsing(...)` automatically when `martis.auth.passwordReset.enabled === true`, pointing the link at the Martis-shipped `martis.password.reset` route (`/{martis-path}/reset-password#token=…&email=…` since v2.6.0).

Since v2.4.0 the link is built on `APP_URL` (`Martis\Support\CanonicalUrl`), never on the request's `Host` or `X-Forwarded-Host`: a reset requested with a forged host used to mail the token to the attacker's domain. The magic link, the invitation and the email-change confirmation are built the same way, so `APP_URL` must be the URL the panel is served on (the package throws a clear error when it is not an absolute http(s) URL, rather than falling back to the request). A signed link (email verification, email change) is signed over that root, so it must be served on the host `APP_URL` names.

The registration is **defensive** — it skips when a callback is already configured by the host app. Consumers who want a custom URL (off-platform reset page, magic-link, deep-link to a mobile app) register their own callback in `AppServiceProvider::boot()`:

```
use Illuminate\Auth\Notifications\ResetPassword;
 
public function boot(): void
{
    ResetPassword::createUrlUsing(function ($notifiable, string $token) {
        return 'https://app.example.com/reset#'.http_build_query([
            'token' => $token,
            'email' => $notifiable->getEmailForPasswordReset(),
        ], '', '&', PHP_QUERY_RFC3986);
    });
}
```

Order of registration doesn't matter: the consumer callback wins because Martis's probe sees the property already set and bails out.

A callback that points at the Martis page builds the link with `Martis\Support\TokenLink::url('martis.password.reset', ['token' => $token, 'email' => $email])`, which puts both in the fragment on `APP_URL`. Keep the token out of the path and the query string of any link you build yourself; see the next section.

### One-time links keep the token out of the request line

Since v2.6.0 every link Martis emails with a one-time credential in it carries that credential in the URL fragment (after `#`), never in the path or the query string:

| Link | Shape |
| --- | --- |
| Password reset | `/{martis-path}/reset-password#token=…&email=…` |
| Invitation | `/{martis-path}/invitations/accept#token=…` |
| Magic-link sign-in | `/{martis-path}/magic-link/confirm#email=…&token=…` |

A browser never sends the fragment to the server, so the token does not reach the request line that reverse proxies, web servers, load balancers and APM or tracing layers log by default. Up to v2.5.x the reset and invitation tokens were path segments (`/reset-password/{token}`, `/invitations/accept/{token}`) and the magic-link token a query parameter: every access log on the way recorded a live token, enough to reset a password, accept an invitation or sign in until it expired or was used.

The page the link opens reads the fragment, drops it from the address bar, and sends the token in the body of its `POST` (`/api/auth/password/reset`, `/api/invitations/accept`, `/api/auth/magic-link/consume`). A page override reads it with `useAuthLinkParams()` from `@martis/runtime` (see [overrides.md](https://getmartis.com/docs/customization/overrides#auth-page-overrides-and-emailed-links)). The page responses are `Cache-Control: no-store, private` with `Referrer-Policy: no-referrer`.

**Links mailed before the upgrade keep working until they expire.** The old shapes are still routed and answer a redirect to the fragment form, without checking the token: `GET /reset-password/{token}?email=…`, `GET /invitations/accept/{token}`, `GET /magic-link/confirm?email=…&token=…` and the pre-v2.4.0 `GET /api/auth/magic-link/consume?…`. The same redirect applies to a `?token=…` query string, or the bare `?<token>`, on `/reset-password` or `/invitations/accept`: what a custom URL callback built with `route('martis.password.reset', ...)` or `route('martis.invitations.accept', $rawToken)` now produces. A redirect away from these pages (the feature is off, the visitor is signed in, the flow points off-platform) drops the fragment, so the token does not follow it to `/login`, the dashboard or another origin. These requests still put the token in the request line, so they are a transition path, not a shape to build: the legacy routes go in v3.0.0. A token that an access log recorded before the upgrade stays valid until it expires or is used (60 minutes for a reset by default, `MARTIS_INVITATIONS_TTL_HOURS` for an invitation, `MARTIS_AUTH_MAGIC_LINK_TTL` for a magic link): if those logs are readable by people who should not hold the tokens, purge them, or revoke and resend pending invitations.

Building a link of your own (a custom `ResetPassword::createUrlUsing()` or `InvitationUrl::createUrlUsing()` callback, a custom notification) follows the same rule. `Martis\Support\TokenLink::url($routeName, $parameters)` returns the named route on `APP_URL` with `$parameters` in the fragment.

Email verification and email-change links are signed URLs: the signature is checked by the server, so it stays in the query string. Following one again only completes the action the account owner asked for, on the address they chose, so it grants no access.

### Graceful errors (v1.8.0)

- **Mailer down on forgot-password.** When the host app's mailer fails (SMTP timeout, invalid credentials, queue worker offline), the `POST /api/auth/password/email` endpoint now catches the throwable and returns a structured `503` with `{message: __('auth.forgot_password_mailer_unavailable')}`. The original exception is still passed to `report()` for monitoring. The frontend toast surfaces the translated message instead of the raw 500 stack trace.
- **Feature off.** When `passwordReset.enabled` or `registration.enabled` is `false`, the forgot-password / register pages bounce the visitor back to `/login` with an info toast in the active locale instead of throwing on mount.
- **Defensive JSON parse on the client.** The shared `api.ts` helper tolerates non-JSON error responses (HTML 404s from misconfigured routes); they no longer leak `SyntaxError: Unexpected token <` to the console.

### Minimal consumer recipe

```
// routes/api.php
Route::post('/martis/api/auth/register', function (Request $request) {
    $data = $request->validate([
        'name'     => ['required', 'string', 'max:255'],
        'email'    => ['required', 'email', 'unique:users,email'],
        'password' => ['required', 'confirmed', 'min:8'],
    ]);
 
    $user = User::create([
        'name' => $data['name'],
        'email' => $data['email'],
        'password' => Hash::make($data['password']),
    ]);
 
    event(new Registered($user));
 
    return response()->json(['user' => $user], 201);
});
```

## Email verification

Martis ships a complete email-verification surface. Off by default — flipping a single config flag wires the middleware, themed pages, and POST endpoint at once.

### When does Martis send a verification email?

Whenever you call `event(new \Illuminate\Auth\Events\Registered($user))`, Laravel core checks whether `$user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail`. If yes, Laravel core dispatches the stock `\Illuminate\Auth\Notifications\VerifyEmail` notification through whichever mailer the host app has configured.

The Martis-shipped `DefaultRegistersUsers` fires `Registered` on every successful signup. So:

- **User model implements `MustVerifyEmail`** → email goes out automatically on signup.
- **User model does not implement `MustVerifyEmail`** → no email is ever sent.

Martis intentionally does not force the contract on the consumer's User model. Some apps do not want email verification at all; others have their own delivery flow (magic links, SSO-only, etc.).

> **Block-on-misconfiguration (v1.8.9+).** When `MARTIS_AUTH_EMAIL_VERIFICATION_ENABLED=true` AND your User model does NOT implement `MustVerifyEmail`, the `martis.verified` middleware now falls back to checking the `email_verified_at` column directly. The standard Laravel `users` migration ships that column, so on a default install:  The user is **blocked** from the panel after registration (column is null). But no verification email is sent (Laravel's listener is keyed on the contract). `DefaultRegistersUsers` writes a clear warning to `laravel.log` so the dev sees the misconfiguration on the first signup.  Pre-v1.8.9 the middleware silently let unverified non-`MustVerifyEmail` users in — a security regression closed by this change. Add `implements MustVerifyEmail` to `App\Models\User` to unblock the email send + verify-link flow.

### Enable the full Martis verification flow

Three steps:

1. **User model** must implement `MustVerifyEmail`: `use Illuminate\Contracts\Auth\MustVerifyEmail; class User extends Authenticatable implements MustVerifyEmail \{ /* … */ }`
2. **Set the master flag**: `MARTIS_AUTH_EMAIL_VERIFICATION_ENABLED=true# optional — redirect off-platform when blocked instead of /email/verifyMARTIS_AUTH_EMAIL_VERIFICATION_NOTICE_URL=`
3. **Mailer must be configured** (Postmark, Resend, Mailgun, SES, SMTP — anything Laravel supports). The verification notification goes through the default mail channel.

### What happens when the flag is on but the mailer isn't?

The package does not pre-validate `MAIL_*` env on boot. Three scenarios depending on the value of `MAIL_MAILER`:

| `MAIL_MAILER` | Effect on registration | Effect on user | What to do |
| --- | --- | --- | --- |
| `log` (Laravel's default on a fresh install) | Registration succeeds. Verification URL is written to `storage/logs/laravel.log`. | Login still blocks the user at `/email/verify` with a "resend" button. The link is reachable from the log file for the dev to forward / paste. | Acceptable in dev. **Set a real mailer before going live** so users actually receive the link. |
| `smtp` / `postmark` / `resend` / `ses` / `mailgun` configured AND reachable | Registration succeeds. Email is sent through the host's mailer. | Standard flow — user clicks link, lands on `/email/verify/{id}/{hash}`, column gets populated, panel unlocks. | Nothing extra. |
| `smtp` (or any external transport) configured but **broken** (wrong host, wrong creds, network unreachable) | `Mail::send` throws. Registration POST returns **500**. The user is created server-side but never sees the success response. | The user can still log in (account exists, unverified), reaches `/email/verify`, hits "Resend" — which throws again. | Fix the mailer. Until then, set `MAIL_MAILER=log` so registration completes cleanly even with no real outbound email. |

A 500 on registration is bad UX, but Martis intentionally does not swallow mailer exceptions: a silent send failure would leave the operator thinking the system worked while every newly registered user is stuck. Loud failure surfaces the misconfiguration immediately. If you want a custom recovery path (e.g. queue the email and retry), bind your own `SendsEmailVerification` implementation — see "Customising the verification email" below.

Once `enabled=true`, the package:

- Registers the `martis.verified` middleware alias and applies it to every protected Martis route. Unverified users hitting `/martis`, `/martis/profile`, `/martis/resources/...` etc. are redirected to `/{martis-path}/email/verify` (or the URL set in `notice_url`).
- Renders the themed notice page at `/{martis-path}/email/verify`. Override via `martis:component MyVerifyNotice --type=email-verify-notice-page`.
- Handles the signed verify link at `/{martis-path}/email/verify/{id}/{hash}` — marks `email_verified_at`, fires `Verified`, redirects to the dashboard.
- Exposes `POST /{martis-path}/api/auth/email/verification-notification` so the notice page can offer a "resend" button.
- Overrides Laravel's `VerifyEmail::createUrlUsing()` so the bundled notification builds its signed link against the Martis-named route (`martis.email.verify`) instead of the framework default `verification.verify` (which Martis does not register). Without this override registration would 500 with `Route [verification.verify] not defined` as soon as the bundled listener runs. The override is gated on the same `enabled` flag and skips when a consumer has already installed its own callback. (v1.8.13+)
- The shared SPA `request()` interceptor watches for `409 { message: "Your email address is not verified." }` and forces a hard navigation to `/{martis-path}/email/verify`. The middleware itself only redirects HTML requests, so without this hook a logged-in unverified user who reached the dashboard via client-side navigation would see every API call 409 in place. Mirrors the existing 401 → `/login?expired=1` behaviour. (v1.8.13+)
- The verification link route (`/email/verify/{id}/{hash}`) is **public** — only the `signed` URL middleware applies. The signed signature plus the `sha1(email)` hash baked into the path are unforgeable proofs of intent on their own, and gating the route behind `martis.auth` broke the most common case (user opens the email on their phone, clicks the link from a logged-out tab, gets bounced to `/login`, signature lost). Logged-out clicks now redirect to `/{martis-path}/login?verified=1` so the SPA shows a "Email verified. You can sign in now." toast. Logged-in clicks land on the dashboard as before. (v1.8.16+)
- Resend-verification throttle dropped from `6/min` to `3/min` (matches the conventional ceiling for password-reset and verification-resend flows). (v1.8.16+)
- Post-register UX: when `email_verification.enabled=true`, the SPA surfaces "Check your inbox to verify your email before signing in." (i18n key `register_success_verify`) and routes to `/email/verify`. Previously the SPA showed "Account created. Please sign in." and routed to `/login`, where the v1.8.14 login gate immediately bounced the user back. The verify-notice page also detects guest visits (no session) and renders i18n key `verify_sub_guest` with a "Back to sign in" CTA in place of the auth-only Resend / Sign-out buttons. The blade exposes `auth.emailVerification.enabled` so the SPA can branch deterministically. (v1.8.16+)

### What if the user loses the verification email?

The shipped notice page (`/{martis-path}/email/verify`) has a "Resend verification link" button. Clicking it calls `POST /{martis-path}/api/auth/email/verification-notification`, which resolves the bound `SendsEmailVerification` implementation and re-dispatches the email.

The endpoint is throttled to 6 attempts per minute per user (`throttle:6,1`) so a stuck loop or a malicious actor cannot flood the inbox. Any consumer-side override can keep its own throttle on top.

If the user cannot reach the page (e.g. signed out completely), they can re-trigger the email by signing in again — the middleware redirects them to the notice page automatically as long as their account is unverified.

### Customising the verification email

Bind your own implementation of `Martis\Contracts\SendsEmailVerification`:

```
// app/Providers/MartisServiceProvider.php
use Martis\Contracts\SendsEmailVerification;
use App\Auth\BrandedVerificationMailer;
 
public function register(): void
{
    $this->app->bind(SendsEmailVerification::class, BrandedVerificationMailer::class);
}
```

```
// app/Auth/BrandedVerificationMailer.php
namespace App\Auth;
 
use Illuminate\Contracts\Auth\Authenticatable;
use Martis\Contracts\SendsEmailVerification;
 
class BrandedVerificationMailer implements SendsEmailVerification
{
    public function send(Authenticatable $user): void
    {
        $user->notify(new \App\Notifications\BrandedVerifyEmail);
    }
}
```

The shipped `EmailVerificationController::send()` resolves this contract from the container, so the consumer's class transparently takes over the resend flow.

### Customising the notice page (Layer 2)

```
php artisan martis:component MyVerifyNotice --type=email-verify-notice-page
```

Same mechanism as Login/Register/Forgot/Reset — see the table in "Customising auth surfaces" → Layer 2.

### Surface lifecycle

| Flag state | Middleware behaviour | Notice page | Verify link | Resend endpoint |
| --- | --- | --- | --- | --- |
| `enabled=false` (default) | Pass-through, no gating | 404 | 404 | 404 |
| `enabled=true`, user verified | Pass-through | 302 → dashboard | Marks verified, 302 → dashboard | 200 (re-sends; harmless) |
| `enabled=true`, user unverified | 302 → notice (or `notice_url`) | 200 (themed page) | Marks verified, 302 → dashboard | 200 |
| `enabled=true`, JSON request | 409 with `{message}` | 409 | normal | normal |

## Password rules

Every Martis surface that sets a password validates it with your app's password policy, `Password::defaults()`, as Nova 5 does (v2.3.0): Profile, registration, password reset, invitation accept, the [forced password change](https://getmartis.com/docs/auth/authentication#forced-password-change) and `martis:user`. Without app defaults the rule is Laravel's `Password::min(8)`. Declare yours once, in a service provider:

```
use Illuminate\Validation\Rules\Password;
 
public function boot(): void
{
    Password::defaults(fn () => Password::min(12)->mixedCase()->numbers()->uncompromised());
}
```

The password checklist of those pages draws the same rules. The Blade shell sends them to the SPA as `window.MartisConfig.auth.passwordRequirements`: minimum and maximum length, mixed case, letters, numbers, symbols, and "not in a known data leak", which only the server checks. A rule of your own that implements `Illuminate\Contracts\Validation\Rule` shows no checklist; the server still enforces it, and its message shows under the field. Anything else (an array of rules, a string, a `ValidationRule`, what `php artisan make:rule` generates) is what Laravel's `Password::default()` silently replaces with `Password::min(8)`, so Martis refuses it: `PasswordPolicy` throws an `InvalidArgumentException` naming what `Password::defaults()` gave. A resource's `Password` field follows the policy with `->defaultRules()` (see [Fields → Password](https://getmartis.com/docs/core/fields#password)).

### PasswordPolicy

`Martis\Auth\PasswordPolicy` is the single source of that policy: every Martis surface, and your own code, reads it from there instead of calling `Password::default()` again.

- `PasswordPolicy::rule()` returns the rule a new password must pass (`Illuminate\Contracts\Validation\Rule`): your `Password::defaults()` result, else (no defaults, or a closure that returns `null`) `Password::min(8)`. It throws an `InvalidArgumentException` when `Password::defaults()` gives anything that is not a `Rule`. Use it in your own validation to follow the same policy: `'password' => ['required', 'confirmed', PasswordPolicy::rule()]`.
- `PasswordPolicy::requirements()` returns the same policy as the array the SPA's checklist reads (`minLength`, `maxLength`, `uppercase`, `lowercase`, `letters`, `number`, `symbol`, `uncompromised`, each present only when the rule demands it), or `null` when the app's default is a rule other than `Password`. It is what the shell sends as `passwordRequirements`. The custom rules of a `Password` (`->rules([...])`) are not in it; the server still enforces them.

The SPA reads two keys of `window.MartisConfig.auth`: `passwordRequirements` (the array above, or `null`) and `passwordChange`, `{ enabled, url }`: whether the [forced password change](https://getmartis.com/docs/auth/authentication#forced-password-change) gate is on, and where a held user goes (the built-in `/{martis-path}/password/change` page unless `MARTIS_AUTH_PASSWORD_CHANGE_URL` names another one, which may be an absolute URL on another origin).

Every password is hashed with your app's hasher (`HASH_DRIVER`), `Hash::make()`.

## Forced password change

Hold a user on a password change page until they choose a new password, for example after an administrator set a temporary one (v2.3.0). Off by default. Nova 5 has no equivalent; the gate mirrors the [email verification](https://getmartis.com/docs/auth/authentication#email-verification) gate.

### Enable it

1. Publish the column and migrate: `php artisan vendor:publish --tag=martis-password-change-migrationphp artisan migrate` The migration adds the boolean column `must_change_password` (`MARTIS_AUTH_PASSWORD_CHANGE_COLUMN`), default `false`, to the table of the users the Martis guard signs in.
2. Turn the gate on: `MARTIS_AUTH_PASSWORD_CHANGE_ENABLED=true`. If you published `config/martis.php` before v2.3.0, it has no `password_change` entry under `auth`, so the variable is never read and the gate stays off: copy the `password_change` block from the package's `config/martis.php` into yours (or republish the config and merge your changes).
3. Flag a user: `$user->forceFill(['must_change_password' => true])->save()`. An action can do it while it sets a temporary password, and show that password once with a [custom modal response](https://getmartis.com/docs/core/actions#custom-modal-responses).

Without the column, and without the contract below, the gate holds nobody: a missing column never locks your users out.

### Your own rule: MustChangePassword

A user model that implements `Martis\Contracts\MustChangePassword` decides itself, and Martis then ignores the column:

```
use Martis\Contracts\MustChangePassword;
 
class User extends Authenticatable implements MustChangePassword
{
    public function mustChangePassword(): bool
    {
        return $this->password_expires_at?->isPast() ?? false;
    }
 
    public function markPasswordChanged(): void
    {
        // Martis saves the user right after this call, with the new password.
        $this->password_expires_at = now()->addDays(90);
    }
}
```

### What the gate does

- **Where it runs.** `martis.password.changed` runs last in the stack of every protected route, after the 2FA challenge, email verification and the panel gate (see [Middleware](https://getmartis.com/docs/auth/authentication#middleware)), so a user with 2FA passes the challenge first.
- **What a held user gets.**  A JSON request answers `409 {"password_change_required": true, "message": "Password change required."}`, and the SPA leaves for the change page. A page redirects to `/{martis-path}/password/change`, or to `MARTIS_AUTH_PASSWORD_CHANGE_URL` when you host your own page. The sign-in answer carries `password_change_required`, and so does the 2FA challenge answer. `GET /api/auth/user` carries `password_change_pending`. The dashboard never paints behind the gate.
- **The page.** `/{martis-path}/password/change` asks for the current password and a new one that follows your [password rules](https://getmartis.com/docs/auth/authentication#password-rules) and differs from the current one. Replace it under `auth:password-change` (`php artisan martis:component --type=password-change-page`).
- **The endpoint.** `POST /{martis-path}/api/auth/password/change` takes `current_password`, `password` and `password_confirmation`. It answers `403` to a user the gate does not hold, and shares the sign-in throttle.
- **Who is never held.** An impersonation (the operator must never choose the user's password), and a session opened through SSO (the user knows no password), also after the session ends and the remember-me cookie the SSO sign-in set signs the user back in: the SSO origin is kept in an encrypted cookie of that user (see [SSO → Federated logout](https://getmartis.com/docs/auth/sso#federated-logout-single-sign-out)). Sign-out, `GET /api/auth/user` and the translations stay reachable.
- **Limitation: the gate is per session.** The bypass for impersonation and SSO is a marker in the session, not a property of the user.  A flagged user who signs in through SSO or a magic link knows no password: do not flag such users. A remember-me restore opens a fresh session without the SSO marker. A flagged user who signed in through SSO is then held, and cannot satisfy `current_password`, which they never knew.
- **What clears the flag.** The change itself, a change from Profile, and a password reset by email: a listener on `Illuminate\Auth\Events\PasswordReset` clears it, so it keeps working if you rebind `ResetsUserPasswords` and still fire the event.
- **After a change.** As Fortify (behind Nova 5's User Security page) does:  the user's pending reset tokens are deleted; `Martis\Events\PasswordChanged` fires, with `$event->user` and `$event->forced` (true on the change page, false from Profile); the remember token is not rotated; other sessions are not signed out unless `auth.session` (Laravel's `AuthenticateSession`) is in `martis.auth_middleware`, which signs them out on their next request.

## Magic-link (passwordless) sign-in

Off by default. When enabled, the Login page exposes a "Email me a sign-in link" button that issues a one-shot token and emails it. The link opens a confirmation page ("Sign in as x@y") and the sign-in happens when the user confirms it: no password required.

### Enable

```
MARTIS_AUTH_MAGIC_LINK_ENABLED=true
# Optional — defaults shown
MARTIS_AUTH_MAGIC_LINK_TTL=15
MARTIS_AUTH_MAGIC_LINK_AUTO_REGISTER=false   # auto-create users for unknown emails
```

Or directly in `config/martis.php`:

```
'magic_link' => [
    'enabled' => env('MARTIS_AUTH_MAGIC_LINK_ENABLED', false),
    'ttl_minutes' => (int) env('MARTIS_AUTH_MAGIC_LINK_TTL', 15),
    'auto_register' => (bool) env('MARTIS_AUTH_MAGIC_LINK_AUTO_REGISTER', false),
],
```

### Endpoints

| Method | Path | Description |
| --- | --- | --- |
| `POST` | `/martis/api/auth/magic-link/request` | `{ email }` → issues a token and emails it. Returns `200 { ok: true }` whether or not the email exists (account-enumeration safe). |
| `GET` | `/martis/magic-link/confirm#email=…&token=…` | The emailed link (v2.4.0; email and token in the URL fragment since v2.6.0, so the server never receives them on this request). Serves the SPA confirmation page, which signs nobody in, so a mail scanner, a link preview, a prefetch or an `<img>` that loads it does nothing (twice over: the token survives). An expired or invalid token is refused by the `POST` below, and the page then goes to `/{martis-path}/login?magic_link=expired` (or `=invalid`); with magic links off the `GET` redirects to `?magic_link=disabled`. A link with the email and token in the query string (mailed by v2.4.0 to v2.5.x) is redirected to the fragment form. The response is `no-store` with `Referrer-Policy: no-referrer`. |
| `POST` | `/martis/api/auth/magic-link/consume` | `{ email, token, replace_session? }`, CSRF-protected. Consumes the token and signs the user in; answers `200 { redirect }`. `422` for an invalid or expired token. `409 { code: "session_conflict" }`, token untouched, when the browser is signed in as another user and `replace_session` is not `true`: the page then asks before replacing that session. |

Up to v2.3.x the emailed link was `GET /api/auth/magic-link/consume` and signed in on load: it let anyone who could make a victim's browser load a URL (an `<img>`, a redirect, a chat link) swap the victim into the attacker's account, and a mail scanner burned the token. That URL now signs in only on `POST`; a `GET` of it (a link mailed before the upgrade) is redirected to the confirmation page with its email and token in the fragment, so the link still works and nothing happens until the user confirms.

### Storage

Tokens are persisted in the same `password_reset_tokens` table Laravel ships with, scoped under an `email` value prefixed with `martis-magic:` so they never clash with reset-password rows. Tokens are hashed before storage; the plaintext token only exists in the email body. Issuing a fresh token deletes any prior token for the same email.

### Security envelope

- **One-shot.** Consuming a token deletes the row, and the delete decides who got it: two simultaneous requests cannot both sign in. Replays return `expired`.
- **On `APP_URL`.** The emailed URL is built from `APP_URL`, never the request's host (see [Password-reset URL routing](https://getmartis.com/docs/auth/authentication#password-reset-url-routing-v183)).
- **No silent session swap.** A browser signed in as another user keeps that session until the confirmation page's explicit `replace_session`.
- **Short TTL.** Default 15 minutes — a magic-link is mailbox-equivalent, so the leak window matches the threat profile.
- **Per-email throttle.** The request endpoint sits behind both per-IP `throttle:N,1` and the `martis-login` named limiter (per-email + IP). A flood against `victim@example.com` is blocked even when distributed across IPs.
- **No account enumeration.** When the email is unknown the endpoint still returns `200 { ok: true }` and sends nothing. The frontend toast is identical to the success path.
- **Auto-register opt-in.** Default false. When you flip it on, an unknown email triggers a user create with a random password before the login completes — useful for invite-by-link flows.
- **The Martis guard's users.** The email is looked up, and auto-registered, in the user provider of the Martis guard (`MARTIS_GUARD`, else the app's default guard), the guard the link signs into (v2.0.0+). v1.x looked it up in `users`: with a custom guard whose model has its own table, the link of a site account signed in the panel user with the same id. A Martis user model without `Illuminate\Notifications\Notifiable` is mailed at the address the link was asked for.

### Customising the email

Bind a subclass of `Martis\Auth\MagicLinkNotification` against the FQCN, or override `MagicLinkController` entirely if you need to swap the storage strategy. The notification uses the standard `MailMessage` shape so consumer-side `php artisan vendor:publish --tag=laravel-notifications` to publish the email template still applies.

## Browser sessions

Profile page gets a "Browser sessions" surface (`<BrowserSessionsSection />`) that lists every active session for the current user — IP, user agent, last activity, and a "This device" badge on the current row. Two revoke flows let the user kick stale devices: per-row trash icon (skipped on the current row to prevent accidental self-logout) and a one-click "Sign out everywhere else" button that drops every other session.

### Enabling

The section is mounted by default when `martis.profile.sections` includes `'sessions'`. Since v1.8.8 the bundled default is `['avatar', 'account', 'password', 'security', 'sessions']`. Older installs that pinned the list to four entries need to add `'sessions'` to the published `config/martis.php` to see the panel.

#### Requirements

The Browser sessions section has an external host dependency (unlike the other profile sections):

| Requirement | Detail |
| --- | --- |
| **Session driver** | `SESSION_DRIVER=database`. On `file` / `cookie` / `array` / unknown the API returns `supported: false` and the UI renders a one-line hint instead of an empty list. |
| **`sessions` table** | The standard Laravel session table must exist. |
| **One user table** | Laravel writes `sessions.user_id` with the id of the request's guard and no table. When the session guards of `config/auth.php` (with the Martis and the default guard) sign in users of more than one table, as a custom `MARTIS_GUARD` with its own model beside the site's `users` does, an id there can be another person's: the API returns `supported: false` with a `reason` the UI shows, and revokes nothing (v2.0.0+). Guards that share one table, through any provider or model, are supported. An admin-only app beside Laravel's merged `web` guard declares the guards that really write the column in [`auth.session_guards`](https://getmartis.com/docs/reference/configuration#session-guards-v2230) (v2.23.0) to lift the refusal. |

**Provisioning (since v1.30.0):** run `php artisan martis:install --with-sessions` to publish a **key-type-aware** sessions migration (published under the `martis-sessions-migration` tag, folded into `--with-profile` when the section is active). Its `user_id` column matches your `users.id` shape — a UUID/ULID-keyed host gets a `uuid`/`ulid` column instead of a `bigint`, avoiding the Postgres `invalid input syntax for type bigint` failure that Laravel's stock `php artisan session:table` (`foreignId('user_id')`) causes on non-integer-keyed users tables. The migration is idempotent (skipped when the table already exists) and carries no FK constraint, matching Laravel's own session table. You may still use `php artisan session:table` + `php artisan migrate` if your `users.id` is a `bigint`.

**Install-time preflight:** when `'sessions'` is in `profile.sections` but `SESSION_DRIVER` isn't `database`, `martis:install` prints an actionable warning (it never fails the install) so the dependency surfaces at setup time rather than only at runtime. To hide the section entirely, remove `'sessions'` from `profile.sections`.

### Endpoints

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/martis/api/profile/sessions` | `{ sessions: [...], supported, driver }`, plus `reason` when the session rows cannot be attributed (see the requirements). Each session row carries `id` (an opaque handle, see below), `ip_address`, `user_agent`, `last_active` (unix seconds), and `is_current`. |
| `DELETE` | `/martis/api/profile/sessions/others` | Revokes every session except the current one. Returns `{ revoked, supported }` (204 when unsupported). |
| `DELETE` | `/martis/api/profile/sessions/{id}` | Revokes a single session by the `id` handle of the list (never the raw session id). Targeting the current session, or a handle that names none of the user's sessions, is a no-op (`revoked: 0`) so the call cannot accidentally sign the user out of the device issuing the request. |

**The `id` of a session is an opaque handle (v2.4.0).** A session's `sessions.id` is the server-side credential of that device's session; the list used to send it to the browser so the client could revoke it, which hands it to anything that can read the response (a script injected in the panel, a logged HAR), and takes the session over in an app that excludes the session cookie from encryption. The `id` the API gives each row is now an HMAC (SHA-256) of the session id and the user with the app key, 64 hex characters: it cannot be turned back into the session id, it differs per user and per `APP_KEY`, and `DELETE /profile/sessions/{id}` resolves it on the server among the user's own sessions (`BrowserSessionsService::handle()`). The raw session id revokes nothing. A custom `martis:profile-sessions` component keeps working: it only has to pass the row's `id` back. Rotating `APP_KEY` changes every handle, which a list loaded before the rotation does not survive (reload it).

### Customisation

- **Backend**: bind your own subclass of `Martis\Profile\BrowserSessionsService` against the FQCN to extend with geo-IP enrichment, audit logging, or cross-device push notifications. The service exposes `forUser(Authenticatable, Request)`, `revokeOthers(Authenticatable, Request)`, and `revoke(Authenticatable, Request, string $id)`.
- **UI**: register a custom React component under the `martis:profile-sessions` registry key from your consumer extension bundle (`resources/js/martis-extensions/`) to swap the bundled `BrowserSessionsSection`. When unset, the bundled component renders.
- **Translations**: keys live under the `profile` namespace: `sessions_title`, `sessions_subtitle`, `sessions_loading`, `sessions_empty`, `sessions_unsupported`, `sessions_unsupported_guards` (the `reason` above), `sessions_current_badge`, `sessions_unknown_ip`, `sessions_revoke`, `sessions_revoke_success`, `sessions_revoke_others`, `sessions_revoke_others_confirm`, `sessions_revoke_others_success`, `sessions_revoking`. All shipped in en, pt_PT, pt_BR.

## Error pages

Three themed error screens are wired into the router:

| Path | Page | Crumb translation key |
| --- | --- | --- |
| `*` (catch-all) | `pages/NotFound.tsx` | `navigation.error_not_found` |
| `/403` | `pages/Forbidden.tsx` | `navigation.error_forbidden` |
| `/500` | `pages/ServerError.tsx` | `navigation.error_server_error` |

Each page renders the shared `components/auth/ErrorScreen.tsx` component. Server-side 500s should redirect to `/{martis-path}/500?incident=inc_...` — the `ServerErrorPage` reads the query string (`incidentId` prop) or falls back to a random placeholder so production installs display the incident id alongside the copy button.

## 2FA challenge redesign

The 2FA challenge at `{martis-path}/2fa/challenge` now follows the design-system spec:

- Back arrow that cancels the challenge (logs the user out) and email label rendered muted next to it.
- Six `.martis-auth-otp-input` cells with auto-advance on input, arrow-key navigation, backspace backtracking, and paste-to-fill support.
- 30-second visual countdown + "Use a backup code" toggle that swaps the OTP row for a plain recovery-code input.
- Auto-submit once all six cells are filled, with inline error on failure and the existing 5-minute inactivity timeout preserved.

## Configuration

In `config/martis.php`:

```
'guard' => env('MARTIS_GUARD', null),  // null = Laravel default guard
'middleware' => ['web'],                // Applied to all routes
'auth_middleware' => ['martis.auth'],   // Applied to protected routes
```

### Custom Guard

To use a separate guard for Martis:

```
// config/auth.php
'guards' => [
    'martis' => [
        'driver' => 'session',
        'provider' => 'users',
    ],
],
 
// config/martis.php
'guard' => 'martis',
```

The panel then runs as that guard: `MartisAuthenticate` makes it the request's guard (v2.0.0+), so `$request->user()`, `auth()->user()`, the gates, the policies and the protected routes' throttle see the user it signed in, an instance of its provider's model. Type policies and gate closures for that model (or `Authenticatable`), give the model `Illuminate\Notifications\Notifiable` for the notification bell, and see [Upgrading → A custom Martis guard](https://getmartis.com/docs/getting-started/upgrading#a-custom-martis-guard).

The auth flows use that guard's provider: the login, the magic link, the registration and invitation accept (the email is unique among that guard's users), the email verification link, SSO and `php artisan martis:user`. Password reset uses the password broker of that guard's users (see [Which password broker resets a password](https://getmartis.com/docs/auth/authentication#which-password-broker-resets-a-password)). When that provider's model has its own table (`admins`), the Martis migrations reference it (the preferences and invitations foreign keys) and add the two-factor and avatar columns to it, and the Browser sessions section reads as unsupported, since the `sessions` table cannot tell an admin's id from a site user's (see [Browser sessions](https://getmartis.com/docs/auth/authentication#browser-sessions)).

## User Profile

The profile page is accessible at `/{martis-path}/profile` and provides:

- **Account Information** — Edit name and email
- **Change Password** — Update password with current password confirmation
- **Profile Picture** — Upload, preview, and remove avatar
- **Two-Factor Authentication** — Enable/disable TOTP-based 2FA

### Profile Configuration

```
// config/martis.php
'profile' => [
    'enabled' => true,                     // Set false to disable entirely
    'resource' => null,                    // Custom ProfileResource FQCN
    'menu' => [
        'label' => null,                   // null = i18n default
        'icon' => 'user',                 // Phosphor icon name
    ],
    'avatar' => [
        'enabled' => true,
        'disk' => 'public',              // Filesystem disk
        'path' => 'avatars',             // Sub-directory
        'max_size_kb' => 2048,           // Max upload size (2MB)
        'column' => 'profile_picture',   // DB column for avatar path
        'url_resolver' => null,          // Custom URL generator (see below)
    ],
    'two_factor' => [
        'enabled' => true,
        'recovery_codes' => 8,           // Number of one-time codes
    ],
    'account' => [
        // MARTIS_PROFILE_EMAIL_EDITABLE — when false, the e-mail cannot be
        // changed from the profile (read-only field, 422 on the server).
        // Default true.
        'email_editable' => true,
    ],
    'sections' => ['account', 'password', 'avatar', 'security', 'sessions'],
],
```

The `enabled` switches hold on the server, not only in the page (v2.8.0): with `avatar.enabled` or `two_factor.enabled` off, the avatar or 2FA routes are not registered and answer `404`, and with 2FA off nobody meets the challenge at sign-in (see [Turning 2FA off](https://getmartis.com/docs/auth/authentication#turning-2fa-off-v280)). `sections` only orders and shows the parts of the page. See [Configuration → Profile](https://getmartis.com/docs/reference/configuration#profile) for the table.

#### Custom avatar URLs

By default the avatar URL comes from the disk (`Storage::disk($disk)->url($path)`). To serve avatars from a CDN or through signed URLs, set `avatar.url_resolver` to the name of an invokable class or a `[Class::class, 'staticMethod']` array. It receives the stored path and returns the public URL, for the upload response and the profile payload alike:

```
'avatar' => [
    'url_resolver' => \App\Martis\AvatarUrl::class, // __invoke(string $storedPath): string
],
```

Both forms survive `php artisan config:cache`; a closure does not. A value that resolves to no callable throws an `InvalidArgumentException` naming the key. See [Configuration → Config keys that take a callable](https://getmartis.com/docs/reference/configuration#config-keys-that-take-a-callable).

#### Changing the email address (v2.4.0)

The address is the identity of the account: it receives the password reset and the sign-in link, and an SSO provider that matches by email adopts the local account that holds it. So `PATCH /api/profile` no longer writes a new address on the spot:

1. A request that changes the address must send `current_password` (`422` without it or with a wrong one, nothing written). The same address in another letter case is no change.
2. The name is saved at once. The address stays as it is, a temporary signed link on `APP_URL` is mailed to the NEW address and a notice to the OLD one, and the answer carries `pending_email`. The profile page then shows "check your new inbox".
3. Opening the link only shows a confirmation page (`GET`, signed): a mail scanner, a link preview or a prefetch loads a mailed link, and an attacker may name someone else's address as the new one, so a bare `GET` never applies the change. Clicking the button on that page sends a CSRF-protected `POST` to the same signed URL, and that checks again that the address is still free, switches it, resets `email_verified_at` for an app that verifies email (and sends the verification link to the new address) and notifies the old address. The link works once; one issued before another change is dead. `martis.profile.email_change.ttl_minutes` (env `MARTIS_PROFILE_EMAIL_CHANGE_TTL`, default 60) sets its lifetime.

**The mail is limited** (v2.4.0): the request mails an address of the user's choosing, so `martis.profile.email_change.throttle_attempts` (env `MARTIS_PROFILE_EMAIL_CHANGE_ATTEMPTS`, default 5) per `throttle_minutes` (`MARTIS_PROFILE_EMAIL_CHANGE_ATTEMPTS_MINUTES`, default 60) bounds both how many a user may ask for and how many one address may be sent, whoever asks (so several accounts cannot mail one address without bound). Past it the profile answers `429` with `Retry-After` before it saves anything. A save that changes no address is never limited; `0` turns the limit off.

Nothing is stored for the pending change (no migration): the link carries the user's id, the new address and a fingerprint of the old one.

#### Locking the e-mail field

The e-mail is often the acting identity, so a deployment may want name, avatar, and password editable while the e-mail stays fixed. Set `profile.account.email_editable` to `false` (env `MARTIS_PROFILE_EMAIL_EDITABLE`):

- the built-in Account section renders the e-mail field read-only;
- `PATCH /martis/api/profile` does not validate the address and keeps it as it is. A request for another address answers `422` (an `email` error) and saves and mails nothing, so no confirmation link is ever sent; a request with the same address, or none, saves the name;
- a confirmation link mailed before the switch went off is refused as `invalid`.

Up to v2.7.0 the flag locked the field in the UI only, and a hand-made `PATCH` still mailed a confirmation link (with no mail service, Laravel's `log` mailer wrote it, token included, to the application log). A custom `ProfileResource` whose `updateRules()` leaves out `email` (see [Custom Profile Resource](https://getmartis.com/docs/auth/authentication#custom-profile-resource)) is no longer needed for that.

### Profile API Endpoints

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/martis/api/profile` | Get current user profile data |
| `PATCH` | `/martis/api/profile` | Update the name. A new email needs `current_password` and is applied only after the confirmation link is followed (see [Changing the email address](https://getmartis.com/docs/auth/authentication#changing-the-email-address-v240)) |
| `GET` | `/martis/profile/email/confirm/{id}` | The signed, temporary link mailed to the new address: opens the confirmation page, changes nothing |
| `POST` | `/martis/profile/email/confirm/{id}` | Applies the change (same signed URL and query string, CSRF-protected). Answers `{outcome, redirect}`: `changed`, `invalid` or `rejected` |
| `POST` | `/martis/api/profile/password` | Change password (validated with your [password rules](https://getmartis.com/docs/auth/authentication#password-rules)) |
| `POST` | `/martis/api/profile/avatar` | Upload avatar (multipart/form-data). Registered only while `profile.avatar.enabled` is on (v2.8.0) |
| `DELETE` | `/martis/api/profile/avatar` | Remove avatar. Registered only while `profile.avatar.enabled` is on (v2.8.0) |

### Custom Profile Resource

The profile resource is the class behind the profile page. `Martis\Contracts\ProfileResourceContract` has three methods:

| Method | Role |
| --- | --- |
| `toArray(Authenticatable $user): array` | The profile data of `GET` and `PATCH /martis/api/profile`: the page reads `name`, `email`, `avatar_url` and `two_factor_enabled`, and the avatar's `avatar_initials` and `avatar_palette`. `/martis/api/auth/user` and the login response take the three avatar keys from it too, so the Topbar shows the avatar the profile page shows. When the array has no `avatar_initials` or `avatar_palette`, Martis adds the initials and palette slot of the user's name (or e-mail), as the `Avatar` and `UiAvatar` fields compute them. |
| `updateRules(Authenticatable $user): array` | The validation rules of `PATCH /martis/api/profile`. |
| `applyUpdate(Authenticatable $user, array $data): void` | Saves the validated data. |

Extend the default `Martis\Profile\ProfileResource` and override what you need, then name the class in `profile.resource`. This one takes the name only, so the e-mail never reaches `applyUpdate()` (to keep the e-mail fixed, [Locking the e-mail field](https://getmartis.com/docs/auth/authentication#locking-the-e-mail-field) is enough since v2.8.0):

```
namespace App\Martis;
 
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Martis\Profile\ProfileResource;
 
class CustomProfileResource extends ProfileResource
{
    public function updateRules(Authenticatable $user): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
        ];
    }
 
    public function applyUpdate(Authenticatable $user, array $data): void
    {
        /** @var Model&Authenticatable $user */
        $user->forceFill(['name' => $data['name']])->save();
    }
}
 
// config/martis.php
'profile' => [
    'resource' => \App\Martis\CustomProfileResource::class,
],
```

The request is validated against `updateRules()` and only the validated keys reach `applyUpdate()`, so an `email` sent to this resource is dropped.

`profile.resource` must name a class that implements the contract. Any other value (a misspelt class, a class that does not implement it) throws an `InvalidArgumentException` naming the key; `null` keeps the default. Up to v1.39.1, a class that did not exist fell back to the default without a word, and `/martis/api/auth/user` always used the default resource, so the Topbar could show another avatar than the profile page.

## Two-Factor Authentication (2FA)

Martis includes TOTP-based two-factor authentication with a guided setup wizard.

### Setup Flow

1. User clicks "Enable 2FA" on the profile page
2. Backend generates a TOTP secret and QR code SVG
3. User scans the QR code with an authenticator app (Google Authenticator, Authy, etc.)
4. User enters the 6-digit verification code
5. Backend verifies the code and generates recovery codes
6. 2FA is now active

### 2FA API Endpoints

These routes are registered only while the panel uses 2FA, `profile.two_factor.enabled` (env `MARTIS_2FA_ENABLED`, default `true`); see [Turning 2FA off](https://getmartis.com/docs/auth/authentication#turning-2fa-off-v280).

| Method | Path | Description |
| --- | --- | --- |
| `POST` | `/martis/api/profile/2fa/setup` | Initialize 2FA (returns QR code SVG + secret) |
| `POST` | `/martis/api/profile/2fa/confirm` | Verify OTP code and activate 2FA |
| `POST` | `/martis/api/profile/2fa/recovery-codes` | Regenerate the recovery-code set for an already-active 2FA account. Takes `current_password` (v2.4.0), and the user is told by email |
| `DELETE` | `/martis/api/profile/2fa` | Disable 2FA for current user. Takes `current_password` |
| `POST` | `/martis/api/2fa/challenge` | Submit 2FA code during login. Rate limited per user and per IP (`MARTIS_2FA_THROTTLE_*`, 5 a minute per user by default) and locks the user out after consecutive wrong codes (`MARTIS_2FA_LOCKOUT_*`), v2.4.0. `422` wrong code, `429` rate limited, `403` locked out |

### 2FA Challenge on Login

When a user with 2FA enabled logs in, while the panel uses 2FA:

1. Initial login returns `{ "two_factor_required": true, "message": "..." }` instead of the user object
2. The frontend redirects to the 2FA challenge screen
3. User enters their 6-digit TOTP code (or a recovery code)
4. On success, the session is fully authenticated

### Turning 2FA off (v2.8.0)

`MARTIS_2FA_ENABLED=false` (`profile.two_factor.enabled`) means the panel does not use 2FA, as Fortify's feature switch does:

- the 2FA section is not on the profile page, and the 2FA routes above (`/api/profile/2fa/*` and `/api/2fa/challenge`) are not registered: they answer `404` and write no `two_factor_secret`;
- nobody meets the challenge at sign-in, an account enrolled earlier included: `POST /api/auth/login` returns the user, not `two_factor_required`, and `martis.2fa` lets the session through. The account's secret and recovery codes stay in the database, unused, and the challenge comes back when the switch is on again.

Up to v2.7.0 the switch hid the section only: the setup endpoints still answered (`500` without the 2FA columns), and an enrolled account still met the challenge with no way left to turn 2FA off. Turn the switch off for a panel that must not use 2FA at all, for instance two apps that share one `users` table but not one `APP_KEY` (the secret is encrypted with the app key, so a user enrolled in one app could not pass the other's challenge). To make 2FA optional per user instead, leave the switch on.

### The 2FA challenge is rate limited and locks out (v2.4.0)

The challenge guards a 6-digit code (with the one step of tolerance either side, about 3 valid codes in a million at any moment), so it has a limiter and a lockout of its own, both in the `throttle` block of `config/martis.php` ([2FA challenge throttle](https://getmartis.com/docs/reference/configuration#2fa-challenge-throttle)):

1. **A limiter** (`martis-2fa-challenge`): 5 requests a minute per user and 15 per IP by default. Past either limit the route answers `429` and does not look at the code, so a right code sent at the 6th attempt is not accepted either. Up to v2.3.0 the challenge shared the login throttle, 20 a minute per user.
2. **A lockout.** After 5 consecutive wrong codes (a TOTP code or a recovery code; a right code starts the count over) the pending session ends (the user is signed out, the session is invalidated) and the route answers `403` with `{"two_factor_locked": true, "message": "..."}`. The SPA shows the message and goes to the login page. The user is then **locked out of the challenge for 15 minutes**: a fresh password sign-in is needed, and during the lockout the challenge refuses every code, the right one and the recovery codes included, and ends the session again. The count belongs to the user, not to the session, so signing in again does not start it over: that is what gives guessing a ceiling for someone who holds the password. The lockout is written to the log (`warning`).

The lockout trades availability for that ceiling: whoever holds the password can keep the owner out of the challenge by failing five times every 15 minutes. If that is a worse risk for your users than the guessing it stops, raise `MARTIS_2FA_LOCKOUT_ATTEMPTS`, shorten `MARTIS_2FA_LOCKOUT_MINUTES`, or set the attempts to `0` to turn the lockout off and keep the limiter. The counters live in the cache (the rate limiter's store), so a cache flush clears them, and a lockout can be cleared by hand with `RateLimiter::clear('martis-2fa-lockout:{guard}:{user id}')`.

### "Keep me signed in" with 2FA (v2.15.0)

The remember-me cookie is issued at the password step, before the challenge, so on its own it proves one factor: a session it starts (once the server session has expired, after `config('session.lifetime')`) meets the challenge again. As in Nova (Fortify issues the cookie only after the challenge), passing the challenge, or confirming the 2FA setup on the profile page, on a device that holds this user's remember-me cookie also leaves a second cookie, `martis_two_factor_remembered`: an HMAC, with the app key, of the user, their remember token, their password hash and their 2FA secret (`Martis\Auth\RememberedTwoFactor`). A later session the remember-me cookie starts keeps the 2FA pass when that cookie still signs this user in and the marker matches, so "Keep me signed in" keeps the user signed in without the code until they sign out.

- A remember-me cookie issued without a passed challenge never gets a marker: whoever holds only the password still meets the challenge on every session.
- Signing out rotates the remember token (Laravel), which voids both cookies; a password change, a reset or "log out other devices" changes the password hash, and setting 2FA up again or turning it off changes the secret: each voids the marker.
- Only a sign-in through the remember-me cookie keeps the pass: a password, magic-link, SSO or custom sign-in in a browser that holds both cookies still meets the challenge.
- The marker is a bearer secret as sensitive as the remember-me cookie: together they skip the code on any browser that holds them, as Nova's remember-me cookie does. One browser holds one marker: a second user who passes the challenge in the same browser replaces it, and the first meets the challenge on their next new session.
- Turning `trust_remember_me` off and on again revives the markers still in browsers whose remember token has not rotated since.
- A session that started before v2.15.0 meets the challenge once more, then gets its marker.
- `MARTIS_2FA_TRUST_REMEMBER_ME=false` (`martis.profile.two_factor.trust_remember_me`) keeps the strict behaviour: no marker, every new session meets the challenge.
- When the challenge is due while a page is open (the session expired and the remember-me cookie signed the user back in), every API call answers `423 {"two_factor_required": true}`, and the panel goes to the challenge page (v2.15.0; it used to show the 423 as "Resource not found").

### The 2FA pass is bound to the user (v2.4.0)

Completing the challenge (or confirming the setup on the profile page) leaves a **pass** in the session: the auth identifier of the user who earned it, under the `martis_two_factor_passed_for` session key (`Martis\Auth\TwoFactorPass`). `martis.2fa` and `GET /api/auth/user` accept the pass only for that user, and every sign-in of the Martis guard forgets it (except a remember-me sign-in that carries the marker of a passed challenge, see ["Keep me signed in" with 2FA](https://getmartis.com/docs/auth/authentication#keep-me-signed-in-with-2fa-v2150)): the password sign-ins (`POST /login` and `POST /api/auth/login`), a magic link, an invitation accept, SSO, the remember-me cookie and the start and stop of an impersonation all fire Laravel's `Illuminate\Auth\Events\Login`, which `Martis\Auth\Listeners\ResetTwoFactorPass` listens to. A user who has 2FA therefore meets the challenge again on every sign-in, and a pass earned by one account never reaches another account signed in later from the same browser session.

Up to v2.3.0 the session held a bare `martis_two_factor_passed` flag that only `POST /api/auth/login` reset: someone who held a victim's password (or mailbox, with magic links on) and any panel account of their own could pass 2FA on their own account, then sign in as the victim through `POST /login` from the same browser session and skip the victim's challenge. A session that still carries the old flag is not trusted: its user meets the challenge once after the upgrade.

An impersonation hands the operator's pass to the target while it lasts (the operator passed their own challenge to start it, and a target who has 2FA cannot be asked for a code the operator does not hold) and gives it back to the operator on stop. An operator the challenge has not cleared (a programmatic `ImpersonationManager::start()`) hands over nothing: the target meets their own challenge.

If you write your own sign-in route, sign the user in through the Martis guard (`Auth::guard(config('martis.guard'))->login($user)`) and the pass is reset for you. If you grant a pass yourself, for instance in a test, use `TwoFactorPass::grant($request->session(), $user)`, or put `[TwoFactorPass::SESSION_KEY => (string) $user->getAuthIdentifier()]` in the session.

### Recovery Codes

When 2FA is enabled, the system generates one-time recovery codes (default: 8). These codes can be used instead of the TOTP code if the user loses access to their authenticator app. Each recovery code can only be used once.

Recovery codes stand in for the TOTP factor at the challenge, so changing them is as sensitive as disabling 2FA (v2.4.0):

- **`POST /api/profile/2fa/recovery-codes` needs `current_password`**, as `DELETE /api/profile/2fa` does. A wrong or missing password answers `422` and leaves the codes alone. The profile page asks for the password in a dialog before it calls the endpoint. Before v2.4.0 the session alone was enough, so whoever held a session that was not the owner's (a stolen or left-open one) could mint a permanent second factor and lock the owner out of their saved codes.
- **The user is told by email** that their recovery codes were regenerated (`Martis\Auth\RecoveryCodesRegeneratedNotification`, a mail notification: extend it to change the wording). The mail goes to the user when the model uses `Illuminate\Notifications\Notifiable`, else to the `email` of the account. A mailer that is down never breaks the request: the codes are already regenerated, and the failure is reported to the exception handler. The subject and lines are the `2fa_regen_mail_*` keys of the `profile` translations (en, pt_PT, pt_BR).
- **The factor- and password-changing endpoints refuse an impersonation.** While an operator impersonates a user ([Impersonation](https://getmartis.com/docs/auth/impersonation)), `POST /api/profile/2fa/setup`, `POST /api/profile/2fa/confirm`, `DELETE /api/profile/2fa`, `POST /api/profile/2fa/recovery-codes` and `POST /api/profile/password` answer `403` (`refused_while_impersonating`) and change nothing. They act on whoever the guard signed in, which is the target during an impersonation, and a recovery code, an authenticator secret or a password the operator set would outlive the session.

### Database Requirements

2FA requires the following columns on the table of the Martis guard's users (`users` unless `MARTIS_GUARD` names a guard whose model has its own table; `martis:install --with-2fa` publishes a migration that adds them there):

```
Schema::table('users', function (Blueprint $table) {
    $table->text('two_factor_secret')->nullable();
    $table->text('two_factor_recovery_codes')->nullable();
    $table->timestamp('two_factor_confirmed_at')->nullable();
    // Replay protection — records the last successful TOTP step so a
    // compromised code cannot be reused within the ±30 s tolerance.
    $table->timestamp('two_factor_last_used_at')->nullable();
});
```

The `martis:install` command includes this migration automatically. Installs that predate the `two_factor_last_used_at` column still verify codes — `TwoFactorService::verifyAndTrack()` probes the schema on first use and skips replay tracking when the column is missing, **logging a warning** (`the two_factor_last_used_at column is missing ...`) so the gap is visible: without the column a TOTP code can be used again for as long as it is valid (about 90 seconds). Add the column to close it. The check does not fail closed: a missing column never locks users out.

### A code is single-use (v2.4.0)

- **A TOTP code.** `two_factor_last_used_at` holds the **start time of the 30-second step** the last accepted code was for (`step * 30`), and any step at or before it is refused. Up to v2.3.0 it held the wall-clock time of the acceptance, so a code accepted for the step after the current one (a client clock running ahead, a code seen before it became current) was accepted again once that step was current. The step start is stored **in UTC**, whatever `app.timezone` is, and read back as UTC: a local wall-clock string is ambiguous for the hour a daylight-saving change repeats, where a newer step would sort before an older one and a valid code be refused. A value written by an earlier version reads as the step it fell in (as UTC, so with an `app.timezone` east of UTC a user who signed in with 2FA in the hours before the upgrade may wait for the next code), and the next acceptance rewrites it. Keep the database session time zone at UTC (the default of most MySQL, PostgreSQL and SQLite setups) so the `timestamp` column holds the string as written.
- **The step is consumed with one conditional update** (`UPDATE ... WHERE two_factor_last_used_at IS NULL OR two_factor_last_used_at < step`). Two requests that carry the same code at once both find it valid, but only one update changes a row; the other is refused. An error while recording the step also refuses the code: a replay guard that cannot record is not one to authenticate through.
- **A recovery code** is consumed inside a transaction that reads the user's row again `FOR UPDATE`, and removes the matching hash only if it is still in the list. Two requests with one recovery code cannot both pass, and a request that read the list before another consumed a different code cannot write the old list back. On SQLite the lock is a no-op (writes are serialised anyway).

## User Menu

The user dropdown menu in the topbar is configurable:

```
// config/martis.php
'user_menu' => [
    'showThemeToggle' => true,      // Dark/light mode toggle
    'showProfile' => true,          // Profile page link
    // 'customItems' => [
    //     ['label' => 'Settings', 'icon' => 'gear', 'url' => '/settings'],
    //     ['separator' => true],
    //     ['label' => 'Docs', 'icon' => 'book-open', 'url' => 'https://docs.example.com'],
    // ],
],
```

### Custom items

Each entry in `customItems` accepts `label`, `icon`, `url`, and an optional `position`. Use `['separator' => true]` for a divider between groups.

| Key | Description |
| --- | --- |
| `label` | Menu text. Resolved through i18n: when the value matches a translation key it is translated (and follows the active locale); otherwise it renders verbatim. Config files can't call `__()`, so pass the key here and it behaves like every other Martis surface. |
| `icon` | **Phosphor icon name** (the same names the sidebar uses, e.g. `key`, `gear`, `book-open`), rendered as an inline SVG through the shared icon path. |
| `url` | Internal path (navigated via the SPA router, like Profile) or a full `https://…` URL (opens in a new tab). A protocol-relative `//host/…` value, or one with a backslash or a control character, is not an internal path: since v2.0.1 it renders as a plain link instead of going through the router. |
| `position` | `'before'` (default) or `'after'`, relative to the built-in Profile entry. `'before'` keeps the item above Profile; `'after'` places it below. |

> **Breaking change (v1.29.0):** `icon` is now a Phosphor icon name, not a PrimeIcons class. Replace legacy values like `'pi pi-key'` with the bare Phosphor name (`'key'`). This aligns the custom-item icons with the built-in Profile / Sign-out entries and fixes the label misalignment caused by the PrimeIcons font-glyph box metrics.

```
'user_menu' => [
    'customItems' => [
        // Translation key → follows the active locale.
        ['label' => 'menu.api_keys', 'icon' => 'key', 'url' => '/api-keys'],
        // Literal label, placed below the Profile entry.
        ['label' => 'Billing', 'icon' => 'credit-card', 'url' => '/billing', 'position' => 'after'],
        ['separator' => true],
        ['label' => 'Docs', 'icon' => 'book-open', 'url' => 'https://docs.example.com'],
    ],
],
```

## On the roadmap: WebAuthn / Passkeys

The current 2FA layer is TOTP-only. Passkey support (WebAuthn ceremony, credential storage, browser API) is a substantial addition that pulls in a third-party library (`web-auth/webauthn-lib`) and a new migration — both decisions that warrant their own design pass. Tracked separately; not shipped in v1.8.8.

The contract layout above (bind your own `Martis\Contracts\*` implementations) is the same surface the future WebAuthn flow will use, so consumer overrides written for TOTP today do not need to change when passkeys ship.

## Middleware

Martis registers these middleware:

| Middleware | Description |
| --- | --- |
| `martis.auth` | Authenticates the user and checks the configured guard. Applied to all protected routes. |
| `martis.sso.session` | Ends an SSO-opened session that is older than `max_session_minutes`, was revoked with `MartisSso::revokeFor()`, or that the provider's `RevalidatesSessions::stillValid()` rejects (v2.21.0, see [SSO](https://getmartis.com/docs/auth/sso#ending-an-sso-session-age-revocation-revalidation-v2210)). Never touches a password, magic-link or invitation session. Applied to all protected routes, right after `martis.auth`. |
| `martis.impersonation.duration` | Stops an impersonation that ran past `MARTIS_IMPERSONATION_MAX_DURATION` minutes. Applied to all protected routes. |
| `martis.2fa` | Ensures users with 2FA enabled have completed the challenge: `423` for a JSON request, a redirect to the challenge screen otherwise. Applied to every protected route but the challenge itself. |
| `martis.locale` | Applies the user's saved language before the controller runs. |
| `martis.verified` | When email verification is enabled, refuses an unverified user: `409` for a JSON request, a redirect to the notice otherwise. |
| `martis.authorize` | When the app defines the `viewMartis` gate, refuses a user it denies (`403`). |
| `martis.password.changed` | When the forced password change is enabled, holds a flagged user: `409` for a JSON request, a redirect to the change page otherwise (v2.3.0). |
| `martis.tool:{uriKey}` | Answers `404` to a user the tool `{uriKey}` is hidden from, and `403` with its lock payload to one it is soft-locked for (v2.4.0). Applied to a Tool's routes (v2.0). |
| `martis.gate` | Answers `403` with the lock payload when a route names a resource, lens, card, dashboard or tool the user is soft-locked from (`lockedFor()`, `requirePlan()`). Applied to the package's API routes that name an entity, not to the two page endpoints that answer the lock themselves, and not part of `martis.api` (v2.4.0, see [Soft-gates](https://getmartis.com/docs/core/gates#what-a-lock-stops-on-the-server)). |
| `martis.api` (group) | The whole stack of a protected API route, from `martis.middleware` to the API throttle, built when the application boots (v2.0). |

These are applied automatically by the Martis route definitions. You do not need to register them manually. The stack of a protected API route is built in one place, `Martis\Http\RouteMiddleware::api()`: `martis.middleware`, `martis.auth_middleware`, `martis.sso.session`, `martis.impersonation.duration`, `martis.2fa`, `martis.locale`, `martis.verified`, `martis.authorize`, `martis.password.changed`, then the API throttle. A Tool's routes run it too, followed by `martis.tool:{uriKey}` (see [Tools → Tool routes and their middleware](https://getmartis.com/docs/customization/tools#tool-routes-and-their-middleware)). It is also the `martis.api` middleware group (v2.0), so a route of your own gets the same guard with `Route::middleware('martis.api')`: `martis.auth` alone lets a user who has not passed the 2FA challenge through.

## Next Steps

- [Resources](https://getmartis.com/docs/core/resources) — Define admin resources
- [Authorization](https://getmartis.com/docs/core/resources#authorization) — Control access with policies
- [Configuration](https://getmartis.com/docs/reference/configuration) — Full config reference

Agent guidelines + MCP

SSO
