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 generouslogin-relaxedfloor instead of removing the limiter entirely — no admin setting can leave web login unthrottled. The API auth routes carry their own hardcodedthrottle:5,1middleware, 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::deletereturnsfalsewhen actor === target, soDELETE /api/v1/users/{self}returns 403 even for users holdingusers.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 →
403in the kit's documentedApiResponseenvelope (built in the middleware, so the shape does not depend onApiExceptionHandlerbeing registered). - Web request → the stateful guard is logged out, the session invalidated, the CSRF token regenerated, then a redirect to the named
loginroute carrying the samesk-auth.inactivecopy the login-time block uses. - Web request whose credential cannot be cut (a token guard reached through the
webgroup), or an app with nologinroute → a plain403. 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. |
mergeConfigFrommerges top-level keys only. Aconfig/starter-kit.phppublished before this release has nosecuritykey at all and inherits the vendor block whole; a file carrying a partialsecurityarray 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 infile,redis,memcachedandcookiestores. On thedatabasedriver 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. PurgeOtherSessionsActionneeds the default guard to be a session guard signed in as the same user; otherwise it throws aLogicExceptioninstead of reporting success.
API Authentication
Passport powers the API side:
- personal access tokens
POST /api/v1/auth/registerandPOST /api/v1/auth/loginare public and throttledPOST /api/v1/auth/two-factor-challengeis public and throttledPOST /api/v1/auth/logoutandGET /api/v1/auth/merequireauth:api
API Auth Flow
registerreturns201with{ user, token }only when email verification is disabled- when email verification is enabled,
registerreturns201with{ user, requires_verification: true }and no token logincan return{ user, token },{ requires_verification: true }, or{ requires_two_factor: true, challenge }two-factor-challengecompletes the API 2FA flow with eithercodeorrecovery_codeand returns{ user, token }on success- clients should branch on
requires_verificationandrequires_two_factorinstead 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) overridesaccess_token_minutes— the value is multiplied by24 * 60to derive minutes.PASSPORT_PERSONAL_TOKEN_MONTHS(months) overridespersonal_token_days— the value is multiplied by30to 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