Authentication

The starter kit combines Laravel Fortify for web authentication and Passport for API authentication.

Web Authentication

Built-in flows include:

  • login
  • register
  • forgot password
  • reset password
  • email verification
  • confirm password
  • two-factor challenge

These screens live under resources/js/pages/Auth/.

When Turnstile is enabled from the settings panel, the login, register, and forgot-password forms render the shared TurnstileWidget and validate cf_turnstile_response server-side.

Profile Security

Authenticated users also get profile security tools:

  • profile info update
  • password update
  • two-factor settings
  • recovery code display and regeneration behind password confirmation
  • browser session management
  • avatar upload and removal

These flows are mounted from the profile screen and related routes in routes/web/profile-route.php.

Password Policy

The password policy is driven by the Settings → Security → Password Policy admin tab. Rules are stored as auth.* setting keys and applied at runtime by PasswordValidationRules.

Setting key What it enforces
auth.password_min_length Minimum character count (default: 8)
auth.password_require_mixed_case Upper and lower case required
auth.password_require_numbers At least one digit required
auth.password_require_symbols At least one symbol required

Every Fortify flow picks up the active rules automatically — registration, password reset, password confirmation, and profile password update. Admin user create/update flows also apply the same rules.

Existing users' passwords are not invalidated when the policy changes — only newly submitted passwords are measured against the current rules.

Password expiry

Setting auth.password_expiry_days to a value greater than 0 enables the EnsurePasswordNotExpired middleware. Authenticated users whose password_changed_at timestamp is older than the configured number of days are redirected to a dedicated, guest-style password-expired screen (route password.expired) until they set a new password. Setting 0 (the default) disables expiry entirely.

password_changed_at is stamped automatically on every password write: registration, password reset, profile update, and admin user create/update. Existing users received a now() back-fill when the migration ran, so they start the expiry clock from the deployment date rather than being immediately expired.

Runtime Rules

  • inactive users cannot start a web session; the Fortify login pipeline blocks accounts whose status is not active
  • login is rate-limited by IP and by email/IP combinations when auth.login_throttle = '1' (the default, strict limiter); setting it to '0' in Settings → Security swaps in a more generous login-relaxed floor instead of removing the limiter entirely — no admin setting can leave web login unthrottled. The API auth routes carry their own hardcoded throttle:5,1 middleware, unaffected by this setting.
  • the two-factor challenge flow has its own limiter
  • the two-factor challenge is single-use — any wrong code, empty submit, or invalid recovery code immediately invalidates the challenge id; the client must re-login to obtain a fresh one
  • the forgot-password POST route receives Turnstile middleware dynamically when the route is matched
  • self-delete is blocked on the API. UserPolicy::delete returns false when actor === target, so DELETE /api/v1/users/{self} returns 403 even for users holding users.delete. The only supported self-removal flow is the password-confirmed Fortify path in the Profile UI.

Cutting off an account that is already signed in

The login-time check above cannot reach a session that is already open — an admin who deactivates a user would otherwise wait for that user's cookie to expire. Two pieces close that window.

EnsureUserIsActive middleware. Registered by StarterKitServiceProvider::boot() as the sk.active alias and appended to the web and api groups, so an existing install picks it up on composer update without touching bootstrap/app.php. On every request it reads status on the authenticated model and terminates when the value is on the operator's deny-list:

  • API / JSON request → 403 in the kit's documented ApiResponse envelope (built in the middleware, so the shape does not depend on ApiExceptionHandler being registered).
  • Web request → the stateful guard is logged out, the session invalidated, the CSRF token regenerated, then a redirect to the named login route carrying the same sk-auth.inactive copy the login-time block uses.
  • Web request whose credential cannot be cut (a token guard reached through the web group), or an app with no login route → a plain 403. Redirecting would loop, because the next request arrives with the same credential attached.

It is deliberately fail-open. It ships into apps whose users.status column the kit does not control, and a mass lockout is far worse than one extra request served to an account disabled a second ago. The request passes through whenever: no listed guard has a user; a listed guard is not declared under auth.guards or throws while resolving; the model carries no status attribute; status is null, a bool, or anything that does not normalise to a string; or the normalised value is simply not on the deny-list — unknown strings included. The middleware never infers "not active, therefore blocked"; it can only block a status that was explicitly listed.

RevokeUserAccessAction. The middleware only acts on requests that pass through the web/api groups. When a user's normalised status transitions into a denied value, this action additionally revokes Passport access and refresh tokens, unredeemed authorization/device codes (which could otherwise still be exchanged for a fresh access token), and the user's database session rows. It fires on a real transition only — editing the name of a user who has been inactive for a year revokes nothing — and never throws into the caller, because the status write has already committed.

Configuration — config/starter-kit.php, security block:

Key Default What it does
security.enforce_active_status true Kill switch. false short-circuits the middleware and the token revocation.
security.active_status_denied ['inactive', 'banned'] The only statuses that terminate a session. Limited on purpose to the two non-active values the shipped userStatus definition produces.
security.active_status_guards ['web', 'api'] Guards inspected on each request.
security.csp_extra_origins [] Extra origins appended to the kit's Content-Security-Policy header.

mergeConfigFrom merges top-level keys only. A config/starter-kit.php published before this release has no security key at all and inherits the vendor block whole; a file carrying a partial security array replaces the vendor one for every nested key it omits. The middleware therefore falls back to class constants (EnsureUserIsActive::ENFORCE_DEFAULT / ::DENIED_DEFAULT / ::GUARDS_DEFAULT) that reproduce the shipped literals exactly, so both populations resolve the same values.

Signing out other sessions

AuthenticateSession in the web group. StarterKitServiceProvider also appends Laravel's AuthenticateSession middleware to the web group, so an existing install picks it up on composer update without touching bootstrap/app.php. It stamps every signed-in browser session with a hash of the user's password and logs the session out on its next request once the two no longer match. A session with no stamp yet (one opened before the update) is stamped, not logged out. A web group that already lists the class or its auth.session alias is left as it is.

  • A password change or reset signs the user out everywhere else. The profile password update, an admin changing the user's password and the forgot-password reset all change the hash, so every other browser and device is signed out, remember-me cookies included. When users change their own password, the browser they are using stays signed in.
  • "Log out other browser sessions" works on every session driver. The profile action checks the password, re-hashes it through the session guard's logoutOtherDevices() and stores a new remember token, which also signs out sessions kept in file, redis, memcached and cookie stores. On the database driver the other session rows are deleted as well, so they leave the sessions list at once. The re-hash runs without model events: password_changed_at (the expiry clock) does not move and the activity log records no password change.
  • PurgeOtherSessionsAction needs the default guard to be a session guard signed in as the same user; otherwise it throws a LogicException instead of reporting success.

API Authentication

Passport powers the API side:

  • personal access tokens
  • POST /api/v1/auth/register and POST /api/v1/auth/login are public and throttled
  • POST /api/v1/auth/two-factor-challenge is public and throttled
  • POST /api/v1/auth/logout and GET /api/v1/auth/me require auth:api

API Auth Flow

  • register returns 201 with { user, token } only when email verification is disabled
  • when email verification is enabled, register returns 201 with { user, requires_verification: true } and no token
  • login can return { user, token }, { requires_verification: true }, or { requires_two_factor: true, challenge }
  • two-factor-challenge completes the API 2FA flow with either code or recovery_code and returns { user, token } on success
  • clients should branch on requires_verification and requires_two_factor instead of assuming every successful auth response contains a token

Passport Configuration

Token lifetimes and the scope catalog are set under the passport key in config/starter-kit.php and applied by StarterKitServiceProvider at boot (a no-op when laravel/passport isn't installed).

Config key (passport.…) Env var Default Effect
provider STARTER_KIT_PASSPORT_PROVIDER users Auth provider backing the auto-registered api guard. The guard is only synthesized when the app hasn't already defined auth.guards.api — a custom guard you defined yourself is never overridden.
access_token_minutes PASSPORT_TOKEN_MINUTES 60 Access token TTL, applied via Passport::tokensExpireIn().
refresh_token_days PASSPORT_REFRESH_TOKEN_DAYS 14 Refresh token TTL, applied via Passport::refreshTokensExpireIn().
personal_token_days PASSPORT_PERSONAL_TOKEN_DAYS 30 Personal Access Token TTL, applied via Passport::personalAccessTokensExpireIn().
scopes — 5 example scopes (users.read, users.write, files.read, files.write, admin) Scope catalog registered with Passport::tokensCan(). It is also the allow-list StoreApiTokenRequest validates against when a PAT is created from /admin/api-tokens — requesting a scope outside this catalog is rejected with a validation error before it ever reaches Passport. The literal * is stripped from the allow-list even if present in the catalog, so a PAT can never be created with the wildcard scope from the UI.
default_scopes — [] (empty) Scope(s) applied via Passport::setDefaultScope(). Only takes effect when scopes is non-empty.

Two legacy env vars still take precedence when set (non-null, non-empty string):

  • PASSPORT_TOKEN_DAYS (days) overrides access_token_minutes — the value is multiplied by 24 * 60 to derive minutes.
  • PASSPORT_PERSONAL_TOKEN_MONTHS (months) overrides personal_token_days — the value is multiplied by 30 to derive days.

Both map to the passport.access_token_days / passport.personal_token_months config keys, which are null by default; leave them unset to use the minutes/days keys above.

Scope enforcement is opt-in. Populating passport.scopes alone restricts nothing — it only registers the catalog and (optionally) the default. To actually gate a route, attach Passport's own scope/scopes middleware (e.g. ->middleware('scope:users.read')). Leaving scopes empty preserves Passport's implicit * scope, so existing clients and tokens keep working unchanged. Note that OAuth2 clients created via /admin/api-clients don't carry scopes at all (removed — see API Clients & Tokens); the scope catalog above applies to Personal Access Tokens only.

API Clients & Tokens

The admin panel provides a UI for managing Passport OAuth2 clients and Personal Access Tokens (PATs):

  • /admin/api-clients — list, create, update, and delete OAuth2 clients
  • /admin/api-tokens — manage Personal Access Tokens

Client secrets and PAT values are shown exactly once in a dismissal-blocked modal at creation time and are never stored in plaintext. See API Clients & Tokens for the full reference.

Notes

  • use Fortify for the browser-facing auth experience
  • use Passport for external or token-based API consumers
  • keep web and API auth concerns separate even when they share the same user model