Documentation menu
HomeDocsAuth & SecurityAuthentication

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 for OAuth/OIDC providers (Azure / Google / GitHub / custom), and 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:

StatusBodyDescription
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 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 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): 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:

ClassRole
.martis-auth-frameFull-viewport wrapper with the accent halo background.
.martis-auth-bgDot-grid overlay with a radial mask.
.martis-auth-cardSurface card (400 px default) that holds the form.
.martis-auth-brandLogo row at the top of the card. No text label — the logo asset already carries the wordmark.
.martis-auth-title / .martis-auth-subPrimary title and muted subtitle.
.martis-auth-divider"or" rule between SSO buttons and email/password.
.martis-auth-footCentered footer matching the Shell footer (© {brand} · Powered by Martis).
.martis-auth-controlsTop-right theme / language toggle strip.
.martis-auth-backCircular back button (used on the 2FA challenge).
.martis-auth-forgotInline "Forgot?" link.
.martis-auth-toggle / .martis-auth-toggle-trackCustom toggle switch for the "Keep me signed in" option.
.martis-auth-otp-row / .martis-auth-otp-input6-cell OTP grid for the 2FA challenge.
.martis-error-screen / .martis-error-code / .martis-error-icon / .martis-error-title / .martis-error-desc / .martis-error-idError screens (404 / 403 / 500) with watermark code and optional incident id.

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:

SurfaceFrontend pageBackend endpointDefault handler
Loginpages/Login.tsxPOST /{martis-path}/api/auth/loginLoginController (on by default; email and password sign-in can be switched off, see Password sign-in switch)
Registerpages/Register.tsxPOST /{martis-path}/api/auth/registerMartis\Auth\DefaultRegistersUsers
Forgot passwordpages/ForgotPassword.tsxPOST /{martis-path}/api/auth/password/emailMartis\Auth\DefaultSendsPasswordResetLinks
Reset passwordpages/ResetPassword.tsxPOST /{martis-path}/api/auth/password/resetMartis\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.

BlockShapePurpose
ssoenabled + providers mapRenders one button per enabled provider. Martis owns the OAuth dance end-to-end. See sso.md.
passwordResetenabled + url + brokerRenders 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).
registrationenabled + url + default_roleRenders the "Create an account" link, hosts /register page and POST endpoint. default_role is auto-assigned via assignRole() when set.
controlstheme + localeTop-right widget visibility on every auth surface.
password_loginenabled + break_glass_emailsSwitches email and password sign-in off for an SSO-only panel (v2.21.0). See Password sign-in switch.
challengeenabled + verifier + site_key + applies_toA captcha hook on the public forms (v2.21.0). See Challenge (captcha) hook.

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 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). 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 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:

KeyPublishedNot published (stopped in v2.21.0)
ssoenabled, 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). 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, …).
passwordResetenabled (off while password sign-in is), urlbroker
registrationenabled, urldefault_role
controlstheme, locale
magicLinkenabledttl_minutes / ttlMinutes, auto_register
emailVerificationenablednotice_url
passwordRequirementsthe app's password policy
passwordChangeenabled, urlcolumn
passwordLoginenabled, breakGlass (v2.23.0: a boolean, true when password sign-in is off and break_glass_emails holds an address)break_glass_emails itself
challengeenabled, siteKey, appliesToverifier
copythe 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.

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.
  • 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) 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'],
],
],
SurfaceRoute (the POST)
loginPOST /login and POST /api/auth/login
forgotPOST /api/auth/password/email
resetPOST /api/auth/password/reset
magic_linkPOST /api/auth/magic-link/request
registerPOST /api/auth/register
invitationPOST /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:

PropType
siteKeystring | nullauth.challenge.site_key
surfacestringThe 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) => voidCall with the token when the widget solves, null when it expires or errors. Stable between renders.
resetSignalnumberChanges 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):

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.

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= valueRegistry keyReplaces
login-pageauth:loginpages/Login.tsx
register-pageauth:registerpages/Register.tsx
forgot-password-pageauth:forgot-passwordpages/ForgotPassword.tsx
reset-password-pageauth:reset-passwordpages/ResetPassword.tsx
email-verify-notice-pageauth:email-verify-noticepages/EmailVerifyNotice.tsx
password-change-pageauth:password-changepages/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.

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),
],
ToggleDefaultEffect when false
themetrueHides 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.
localetrueHides 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:

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:

KeyRenders on
login_titleLogin page heading
login_subLogin page subtitle (no SSO providers configured)
login_sub_v2Login page subtitle (SSO providers visible)
register_title / register_subRegister page
forgot_password_title / forgot_password_subForgot-password page
reset_password_title / reset_password_subReset-password page
invitation_accept_title / invitation_accept_subInvitation-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.

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:

LinkShape
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). 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/verify
    MARTIS_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_MAILEREffect on registrationEffect on userWhat 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 reachableRegistration 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 stateMiddleware behaviourNotice pageVerify linkResend endpoint
enabled=false (default)Pass-through, no gating404404404
enabled=true, user verifiedPass-through302 → dashboardMarks verified, 302 → dashboard200 (re-sends; harmless)
enabled=true, user unverified302 → notice (or notice_url)200 (themed page)Marks verified, 302 → dashboard200
enabled=true, JSON request409 with {message}409normalnormal

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

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

Enable it

  1. Publish the column and migrate:

    php artisan vendor:publish --tag=martis-password-change-migration
    php 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.

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), 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 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). 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.

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

MethodPathDescription
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).
  • 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):

RequirementDetail
Session driverSESSION_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 tableThe standard Laravel session table must exist.
One user tableLaravel 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 (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

MethodPathDescription
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/othersRevokes 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:

PathPageCrumb translation key
* (catch-all)pages/NotFound.tsxnavigation.error_not_found
/403pages/Forbidden.tsxnavigation.error_forbidden
/500pages/ServerError.tsxnavigation.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.

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

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). sections only orders and shows the parts of the page. See 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.

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) is no longer needed for that.

Profile API Endpoints

MethodPathDescription
GET/martis/api/profileGet current user profile data
PATCH/martis/api/profileUpdate the name. A new email needs current_password and is applied only after the confirmation link is followed (see Changing the email address)
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/passwordChange password (validated with your password rules)
POST/martis/api/profile/avatarUpload avatar (multipart/form-data). Registered only while profile.avatar.enabled is on (v2.8.0)
DELETE/martis/api/profile/avatarRemove 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:

MethodRole
toArray(Authenticatable $user): arrayThe 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): arrayThe validation rules of PATCH /martis/api/profile.
applyUpdate(Authenticatable $user, array $data): voidSaves 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 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.

MethodPathDescription
POST/martis/api/profile/2fa/setupInitialize 2FA (returns QR code SVG + secret)
POST/martis/api/profile/2fa/confirmVerify OTP code and activate 2FA
POST/martis/api/profile/2fa/recovery-codesRegenerate 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/2faDisable 2FA for current user. Takes current_password
POST/martis/api/2fa/challengeSubmit 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):

  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): 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), 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.

KeyDescription
labelMenu 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.
iconPhosphor icon name (the same names the sidebar uses, e.g. key, gear, book-open), rendered as an inline SVG through the shared icon path.
urlInternal 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:

MiddlewareDescription
martis.authAuthenticates the user and checks the configured guard. Applied to all protected routes.
martis.sso.sessionEnds 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). Never touches a password, magic-link or invitation session. Applied to all protected routes, right after martis.auth.
martis.impersonation.durationStops an impersonation that ran past MARTIS_IMPERSONATION_MAX_DURATION minutes. Applied to all protected routes.
martis.2faEnsures 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.localeApplies the user's saved language before the controller runs.
martis.verifiedWhen email verification is enabled, refuses an unverified user: 409 for a JSON request, a redirect to the notice otherwise.
martis.authorizeWhen the app defines the viewMartis gate, refuses a user it denies (403).
martis.password.changedWhen 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.gateAnswers 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).
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). 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