⚙️ Configuration Reference¶
This guide covers every configuration option available in Daycry Auth.
The Configuration Files¶
Configuration is split across three files to keep concerns separate. After running php spark auth:setup, each is published to app/Config/:
| File | Base class | Contains |
|---|---|---|
app/Config/Auth.php |
Daycry\Auth\Config\Auth |
Authenticators, actions, views, tables, routes, session/remember-me |
app/Config/AuthSecurity.php |
Daycry\Auth\Config\AuthSecurity |
Passwords, lockout, rate-limit, token lifetimes, TOTP issuer, permission cache |
app/Config/AuthOAuth.php |
Daycry\Auth\Config\AuthOAuth |
OAuth provider definitions |
Override only what you need in each file:
// app/Config/Auth.php
namespace Config;
use Daycry\Auth\Config\Auth as BaseAuth;
class Auth extends BaseAuth { /* ... */ }
// app/Config/AuthSecurity.php
namespace Config;
use Daycry\Auth\Config\AuthSecurity as BaseAuthSecurity;
class AuthSecurity extends BaseAuthSecurity { /* ... */ }
// app/Config/AuthOAuth.php
namespace Config;
use Daycry\Auth\Config\AuthOAuth as BaseAuthOAuth;
class AuthOAuth extends BaseAuthOAuth { /* ... */ }
Database¶
Database Group¶
// Use the default database connection (recommended)
public ?string $DBGroup = null;
// Use a dedicated auth database
public ?string $DBGroup = 'auth_db';
Table Names¶
Customize any table name to avoid conflicts with your existing schema:
public array $tables = [
'users' => 'users', // Main users table
'identities' => 'auth_users_identities', // Passwords, tokens, TOTP secrets
'logins' => 'auth_logins', // Login attempt log
'remember_tokens' => 'auth_remember_tokens', // "Remember me" cookies
'groups' => 'auth_groups', // Group definitions
'groups_users' => 'auth_groups_users', // User ↔ group assignments
'permissions' => 'auth_permissions', // Permission definitions
'permissions_users' => 'auth_permissions_users', // User direct permissions
'permissions_groups' => 'auth_permissions_groups', // Group permissions
'logs' => 'auth_logs', // Activity log
'apis' => 'auth_apis', // API registry (discovery)
'controllers' => 'auth_controllers', // Controller registry
'endpoints' => 'auth_endpoints', // Endpoint registry
'attempts' => 'auth_attempts', // IP-based failed attempts
'rates' => 'auth_rates', // Rate limit counters
'device_sessions' => 'auth_device_sessions', // Active device sessions
'webauthn_credentials' => 'auth_webauthn_credentials', // WebAuthn / passkey credentials
];
Authenticators¶
Available Authenticators¶
use Daycry\Auth\Authentication\Authenticators\AccessToken;
use Daycry\Auth\Authentication\Authenticators\Session;
use Daycry\Auth\Authentication\Authenticators\JWT;
use Daycry\Auth\Authentication\Authenticators\Guest;
public array $authenticators = [
'access_token' => AccessToken::class,
'session' => Session::class,
'jwt' => JWT::class,
'guest' => Guest::class,
];
// The authenticator used when you call auth() with no argument
public string $defaultAuthenticator = 'session';
JWT Adapter¶
use Daycry\Auth\Authentication\JWT\Adapters\DaycryJWTAdapter;
public string $jwtAdapter = DaycryJWTAdapter::class;
Authentication Chain¶
The chain filter tries authenticators in order and stops at the first success:
public array $authenticationChain = [
'session', // Try session first (web users)
'access_token', // Then access tokens (API keys)
'jwt', // Finally JWT (Bearer tokens)
];
Session Authenticator¶
public array $sessionConfig = [
'field' => 'user', // Session key name
'allowRemembering' => true, // Enable "remember me"
'rememberCookieName' => 'remember', // Cookie name for remember me
'rememberLength' => 30 * DAY, // Remember me duration
// Track active device sessions (login from each device/browser)
'trackDeviceSessions' => false, // Set true to enable
];
Remember-Me Token Purging¶
File:
app/Config/AuthSecurity.php
// Probability (1–100) that EXPIRED remember-me tokens are purged inline on
// login. 0 = never purge on the request path (the default).
public int $rememberMePurgeChance = 0;
| Property | Default | Meaning |
|---|---|---|
$rememberMePurgeChance |
0 |
Percent chance an interactive login also runs an inline purge of expired tokens. 0 disables inline purging entirely |
Why the default is now 0. Token expiry is enforced at validation time (RememberMe::checkRememberMeToken()), so an expired remember-me cookie can never authenticate regardless of whether the row has been purged. Inline purging is therefore table maintenance only — not a security control — and paying a full-table scan on a fraction of interactive logins is no longer worthwhile. Schedule the php spark auth:purge command instead.
User Settings¶
// Allow public registration
public bool $allowRegistration = true;
// Default group assigned to every new user (must exist in auth_groups table)
public string $defaultGroup = 'user';
// Class responsible for finding users in the database
public string $userProvider = \Daycry\Auth\Models\UserModel::class;
// Which fields can be used to log in
public array $validFields = [
'email',
// 'username', // Uncomment to allow username login
];
Password Settings¶
File:
app/Config/AuthSecurity.php
// Minimum password length
public int $minimumPasswordLength = 8;
// Validators that run on every password
public array $passwordValidators = [
\Daycry\Auth\Authentication\Passwords\CompositionValidator::class, // Requires mixed chars
\Daycry\Auth\Authentication\Passwords\NothingPersonalValidator::class, // No personal info
\Daycry\Auth\Authentication\Passwords\DictionaryValidator::class, // No dictionary words
// \Daycry\Auth\Authentication\Passwords\PwnedValidator::class, // HaveIBeenPwned check
];
// HaveIBeenPwned API URL and HTTP timeouts (used when PwnedValidator is enabled).
// Short timeouts prevent registration / password-change flows from hanging when
// the API is slow or unreachable.
public string $pwnedPasswordsApiUrl = 'https://api.pwnedpasswords.com/';
public float $pwnedPasswordsConnectTimeout = 1.0; // seconds
public float $pwnedPasswordsTimeout = 3.0; // seconds
// Hash algorithm (PASSWORD_DEFAULT, PASSWORD_BCRYPT, PASSWORD_ARGON2I, PASSWORD_ARGON2ID)
public string $hashAlgorithm = PASSWORD_DEFAULT;
public int $hashCost = 12; // bcrypt cost (4–31)
// argon2 options
public int $hashMemoryCost = 65536; // KB
public int $hashTimeCost = 4; // Iterations
public int $hashThreads = 1; // Threads
// Personal data that passwords should not resemble
public array $personalFields = ['firstname', 'lastname', 'phone'];
// 0–100: max allowed similarity between password and personal fields (0 = disabled)
public int $maxSimilarity = 50;
Password Reset¶
File:
app/Config/AuthSecurity.php
// How long a password reset token remains valid
public int $passwordResetLifetime = HOUR; // Default: 1 hour
The reset flow is handled by PasswordResetController. Users request a reset link, receive it by email, and click it within this window. See Controllers for the full setup.
Per-User Account Lockout¶
File:
app/Config/AuthSecurity.php
Independent of IP-based blocking, this locks an individual account after too many failed password attempts:
// Max failed attempts before locking the account (0 = disabled)
public int $userMaxAttempts = 5;
// How long to keep the account locked (seconds)
public int $userLockoutTime = 3600; // 1 hour
When the lockout expires, the counter resets automatically on the next login attempt. See Logging & Monitoring for details and admin unlock instructions.
Now also guards TOTP / backup-code verification. The same per-user lockout (
UserLockoutManager) is applied to two-factor verification: a wrong TOTP / backup code callsrecordFailedAttempt(), an account already over$userMaxAttemptsis blocked byisLockedOut(), and a correct code callsresetOnSuccess(). This closes the brute-force window on the 6-digit second factor. See TOTP — Lockout & Anti-Replay.
Magic Links¶
File:
app/Config/AuthSecurity.phpFor flow details, security properties (anti-enumeration, code hashing, brute-force lockout) and email template customization, see Magic Link Authentication in the Authentication guide.
The passwordless email login supports two delivery modes:
- Link mode — the user receives a one-time, single-use email link to click.
- Code mode — the user receives a 6-digit code to type into a form (session-bound, per-user lockout, hashed at rest).
public bool $allowMagicLinkLogins = true; // Master switch (disables both modes)
public int $magicLinkLifetime = HOUR; // Link expiry (seconds)
public bool $magicLinkEnableLink = true; // Offer the "email me a link" button
public bool $magicLinkEnableCode = true; // Offer the "email me a code" button
public int $magicCodeLifetime = 10 * MINUTE; // Code expiry (seconds; kept short, single-use)
With $allowMagicLinkLogins = false the entire feature is disabled. When both enable flags are true the login form offers both buttons; set $magicLinkEnableLink = false or $magicLinkEnableCode = false to hide the corresponding button. Both modes honour pending post-auth actions and record login attempts.
Access Tokens¶
File:
app/Config/AuthSecurity.php
public bool $accessTokenEnabled = false;
// How long an unused access token remains valid
public int $unusedAccessTokenLifetime = YEAR;
// Force both API key AND session authentication (strict mode)
public bool $strictApiAndAuth = false;
// Minimum seconds between two consecutive `last_used_at` writes for the
// same access token. Prevents one DB UPDATE per request on high-traffic
// API tokens. Set to 0 to disable throttling (write on every request).
public int $tokenLastUsedThrottle = 60;
// Request headers for each authenticator
public array $authenticatorHeader = [
'access_token' => 'X-API-KEY',
'jwt' => 'Authorization',
];
JWT Refresh Tokens¶
File:
app/Config/AuthSecurity.php
When using JwtController for stateless JWT authentication, refresh tokens allow issuing new access tokens without re-entering credentials:
// How long a JWT refresh token remains valid
public int $jwtRefreshLifetime = 30 * DAY; // Default: 30 days
Refresh tokens are stored in auth_users_identities (type: jwt_refresh) and are one-time use (rotated on each refresh). JwtController routes login / refresh / logout through service('jwtTokenRepository'); logout soft-revokes the refresh token. See Authentication — JWT Refresh Tokens.
Revoking Issued JWT Access Tokens¶
JWT access tokens are signed and self-contained, so they cannot be deleted server-side. To support "log out everywhere", each user carries a users.token_version counter (int, default 0, added by migration 2026-05-08-000001). JwtController mints the access-token payload as {uid, tv} where tv is the user's current token_version; the JWT authenticator's check() rejects any token whose embedded tv no longer matches the user's token_version, returning lang('Auth.revokedToken'). (Legacy scalar payloads — a bare user id — are still accepted, with the tv check skipped.)
Call User::revokeIssuedTokens() to bump token_version atomically and invalidate all outstanding access tokens at once. It is invoked automatically by Bannable::ban() and by Services\PasswordChangeRecorder::record() (password reset / change). See Authentication — Revoking JWT Access Tokens.
Logging & Monitoring¶
File:
app/Config/AuthSecurity.php
Activity Logs¶
// Write auth events to auth_logs table
public bool $enableLogs = false;
// Login attempt recording
// 0 = none, 1 = failures only, 2 = all attempts
public int $recordLoginAttempt = 2;
IP-Based Failed Attempt Blocking¶
public bool $enableInvalidAttempts = false;
public int $maxAttempts = 10; // Attempts before blocking
public int $timeBlocked = 3600; // Seconds
Rate Limiting¶
// Identify rate limit subject: 'IP_ADDRESS', 'USER', 'METHOD_NAME', 'ROUTED_URL'
public string $limitMethod = 'METHOD_NAME';
public int $requestLimit = 10; // Max requests per window
public int $timeLimit = MINUTE; // Window size in seconds
These values are the global defaults applied by the rates filter. They can be overridden per route — see Per-Route Rate Limits below.
Per-Route Rate Limits¶
The registered filter alias is rates. It honours per-route arguments that override the global limit / window for that route:
// app/Config/Routes.php
// Use the global $requestLimit / $timeLimit
$routes->post('contact', 'Contact::send', ['filter' => 'rates']);
// Override the limit only (10 requests, global window)
$routes->post('login', 'Auth::login', ['filter' => 'rates:10']);
// Override limit AND window: 50 requests per minute
$routes->post('api/search', 'Api::search', ['filter' => 'rates:50,MINUTE']);
// The window may also be a raw number of seconds
$routes->post('api/heavy', 'Api::heavy', ['filter' => 'rates:5,90']);
rates:<limit>,<period> — <period> is either a number of seconds or a named unit. Recognised units (case-insensitive):
| Unit | Seconds |
|---|---|
SECOND |
1 |
MINUTE |
60 |
HOUR |
3600 |
DAY |
86400 |
WEEK |
604800 |
A configured endpoint DB row (runtime / admin override) still wins over both the global config and per-route arguments. Exceeding the limit returns HTTP 429 with lang('Auth.throttled', ...).
Authorization Cache¶
File:
app/Config/AuthSecurity.php
Avoid repeated database queries for group/permission checks in production:
public bool $permissionCacheEnabled = false; // true = use CI4 cache
public int $permissionCacheTTL = 300; // Cache lifetime in seconds
The cache is automatically invalidated when you call addGroup(), removeGroup(), addPermission(), or removePermission(). See Authorization — Permission Cache.
Gate → RBAC Fallback¶
// When true, a Gate ability whose name looks like an RBAC permission
// (contains a scope, e.g. "users.edit") and has no registered closure or
// policy falls back to User::can(). This lets `gate:users.edit` and
// `permission:users.edit` share semantics.
// false = the Gate and RBAC systems stay fully independent.
public bool $gateFallbackToRbac = true;
The gate filter honours this setting, so gate:users.edit resolves a registered Gate ability if one exists and otherwise defers to the user's RBAC permissions. See Authorization.
Performance: Hot-Path Write Throttles¶
File:
app/Config/AuthSecurity.php
On every authenticated request the active authenticator records when the user was last seen. To avoid a users-table UPDATE (and an access-token UPDATE) on every single request, both writes are throttled:
// If true, record the logged-in user's last_active timestamp on each request.
public bool $recordActiveDate = true;
// Minimum seconds between two consecutive `users.last_active` writes for the
// same user (applies only when $recordActiveDate is true). Avoids one
// users-table UPDATE per request on the authenticated hot path.
// 0 = write on every request (the legacy behaviour).
public int $activeDateThrottle = 60;
// Minimum seconds between two consecutive `last_used_at` writes for the same
// access token (see the Access Tokens section above).
// 0 = write on every request.
public int $tokenLastUsedThrottle = 60;
| Property | Default | Meaning |
|---|---|---|
$recordActiveDate |
true |
Record users.last_active for the authenticated user each request |
$activeDateThrottle |
60 |
Min seconds between users.last_active writes per user. 0 = every request |
$tokenLastUsedThrottle |
60 |
Min seconds between last_used_at writes per access token. 0 = every request |
Views¶
Override any built-in view with your own:
public array $views = [
// Core
'login' => '\Daycry\Auth\Views\login',
'register' => '\Daycry\Auth\Views\register',
'layout' => '\Daycry\Auth\Views\layout',
// Email 2FA
'action_email_2fa' => '\Daycry\Auth\Views\email_2fa_show',
'action_email_2fa_verify' => '\Daycry\Auth\Views\email_2fa_verify',
'action_email_2fa_email' => '\Daycry\Auth\Views\Email\email_2fa_email',
// Email Activation
'action_email_activate_show' => '\Daycry\Auth\Views\email_activate_show',
'action_email_activate_email' => '\Daycry\Auth\Views\Email\email_activate_email',
// Magic Link
'magic-link-login' => '\Daycry\Auth\Views\magic_link_form',
'magic-link-message' => '\Daycry\Auth\Views\magic_link_message',
'magic-link-email' => '\Daycry\Auth\Views\Email\magic_link_email',
'magic-link-code' => '\Daycry\Auth\Views\magic_link_code',
'magic-link-code-email' => '\Daycry\Auth\Views\Email\magic_link_code_email',
// Password Reset
'password-reset-request' => '\Daycry\Auth\Views\password_reset_request',
'password-reset-message' => '\Daycry\Auth\Views\password_reset_message',
'password-reset-form' => '\Daycry\Auth\Views\password_reset_form',
'password-reset-email' => '\Daycry\Auth\Views\Email\password_reset_email',
// Force Password Reset
'force-password-reset' => '\Daycry\Auth\Views\force_password_reset',
// Email Change Confirmation
'email-change-email' => '\Daycry\Auth\Views\Email\email_change_email',
// WebAuthn / Passkeys (only used when AuthSecurity.$webauthnEnabled is true)
'webauthn_setup' => '\Daycry\Auth\Views\webauthn_setup',
'webauthn_2fa_verify' => '\Daycry\Auth\Views\webauthn_2fa_verify',
];
The
webauthn_setupview renders the passkey enrollment widget;webauthn_2fa_verifyis shown by theWebauthn2FAlogin action. Both are overridable like any other view. See WebAuthn / Passkeys.
Redirects¶
public array $redirects = [
'register' => '/', // After successful registration
'login' => '/', // After successful login
'logout' => 'login', // After logout (route name or URL)
'force_reset' => '/', // After forced password reset
'permission_denied' => '/', // GroupFilter/PermissionFilter denial
'group_denied' => '/', // GroupFilter denial
];
Override with a method for dynamic redirects:
public function loginRedirect(): string
{
$user = auth()->user();
return $user->inGroup('admin') ? site_url('admin') : site_url('dashboard');
}
Routes¶
All auth routes are defined here. Each entry is [method, uri, controller::method, routeName?]:
public array $routes = [
'register' => [
['get', 'register', 'RegisterController::registerView', 'register'],
['post', 'register', 'RegisterController::registerAction'],
],
'login' => [
['get', 'login', 'LoginController::loginView', 'login'],
['post', 'login', 'LoginController::loginAction'],
],
'magic-link' => [
['get', 'login/magic-link', 'MagicLinkController::loginView', 'magic-link'],
['post', 'login/magic-link', 'MagicLinkController::loginAction'],
['get', 'login/magic-link/message', 'MagicLinkController::messageView', 'magic-link-message'],
['get', 'login/verify-magic-link', 'MagicLinkController::verify', 'verify-magic-link'],
['get', 'login/magic-link/code', 'MagicLinkController::codeView', 'magic-link-code'],
['post', 'login/magic-link/code', 'MagicLinkController::verifyCode'],
],
'logout' => [
['get', 'logout', 'LoginController::logoutAction', 'logout'],
],
'auth-actions' => [
['get', 'auth/a/show', 'ActionController::show', 'auth-action-show'],
['post', 'auth/a/handle', 'ActionController::handle', 'auth-action-handle'],
['post', 'auth/a/verify', 'ActionController::verify', 'auth-action-verify'],
],
// Password Reset
'password-reset' => [
['get', 'password-reset', 'PasswordResetController::requestView', 'password-reset'],
['post', 'password-reset', 'PasswordResetController::requestAction'],
['get', 'password-reset/message', 'PasswordResetController::messageView', 'password-reset-message'],
['get', 'password-reset/(:any)', 'PasswordResetController::resetView', 'password-reset-form'],
['post', 'password-reset/(:any)', 'PasswordResetController::resetAction'],
],
// Force Password Reset
'force-reset' => [
['get', 'force-reset', 'ForcePasswordResetController::showView', 'force-reset'],
['post', 'force-reset', 'ForcePasswordResetController::resetAction'],
],
// JWT (stateless, for APIs)
'jwt' => [
['post', 'auth/jwt/login', 'JwtController::login', 'jwt-login'],
['post', 'auth/jwt/refresh', 'JwtController::refresh', 'jwt-refresh'],
['post', 'auth/jwt/logout', 'JwtController::logout', 'jwt-logout'],
],
// OAuth 2.0 / social login
'oauth' => [
['get', 'oauth/login/(:segment)', 'OauthController::redirect/$1', 'oauth-login'],
['get', 'oauth/callback/(:segment)', 'OauthController::callback/$1', 'oauth-callback'],
['get', 'oauth/link/(:segment)', 'OauthController::link/$1', 'oauth-link'],
],
// WebAuthn / Passkeys — registered ONLY when AuthSecurity.$webauthnEnabled is true
'webauthn' => [
['post', 'webauthn/register/options', 'WebAuthnController::registerOptions'],
['post', 'webauthn/register/verify', 'WebAuthnController::registerVerify'],
['post', 'webauthn/login/options', 'WebAuthnController::loginOptions'],
['post', 'webauthn/login/verify', 'WebAuthnController::loginVerify'],
['post', 'webauthn/2fa/options', 'WebAuthnController::twoFactorOptions'],
['post', 'webauthn/credentials/(:segment)/delete', 'WebAuthnController::deleteCredential/$1'],
],
];
The webauthn route group is auto-gated by AuthSecurity::$webauthnEnabled (default false): auth()->routes($routes) registers these routes only when the flag is on, and WebAuthnController re-checks and returns 404 otherwise (defense in depth). See WebAuthn / Passkeys — Routes & JSON Endpoints for the full contract.
The oauth-link route (GET oauth/link/(:segment)) is the explicit, user-initiated linking flow: it requires an authenticated user, stashes the current user (oauth_link_user_id), and the shared callback then links the provider to the current user — no e-mail merge and no verified-email requirement, because the user is acting deliberately. Linking a social account that is already bound to a different local user is refused with lang('Auth.oauthAlreadyLinked'). See OAuth 2.0 & Social Login.
Post-Authentication Actions¶
Run additional verification steps after login or registration:
use Daycry\Auth\Authentication\Actions\Email2FA;
use Daycry\Auth\Authentication\Actions\EmailActivator;
use Daycry\Auth\Authentication\Actions\Totp2FA;
use Daycry\Auth\Authentication\Actions\Webauthn2FA;
public array $actions = [
'register' => EmailActivator::class, // Require email confirmation on signup
'login' => Email2FA::class, // Require email 2FA on login
// 'login' => Totp2FA::class, // Or: require TOTP on login (per-user)
// 'login' => Webauthn2FA::class, // Or: require a passkey on login (per-user)
];
| Action | What It Does |
|---|---|
Email2FA |
Sends a 6-digit code to the user's email; required to complete login |
EmailActivator |
Sends an activation link; user must click before first login |
Totp2FA |
Requires a valid TOTP code (only for users who have enrolled) |
Webauthn2FA |
Requires a passkey assertion (only for users who have enrolled). Requires AuthSecurity.$webauthnEnabled = true. Mutually exclusive with Totp2FA — only one login action is supported. See WebAuthn / Passkeys |
Field Validation Rules¶
public array $usernameValidationRules = [
'label' => 'Auth.username',
'rules' => [
'required',
'max_length[30]',
'min_length[3]',
'regex_match[/\A[a-zA-Z0-9\.]+\z/]',
],
];
public array $emailValidationRules = [
'label' => 'Auth.email',
'rules' => [
'required',
'max_length[254]',
'valid_email',
],
];
OAuth Providers¶
File:
app/Config/AuthOAuth.php
public array $providers = [
'google' => [
'clientId' => env('OAUTH_GOOGLE_CLIENT_ID'),
'clientSecret' => env('OAUTH_GOOGLE_CLIENT_SECRET'),
'redirectUri' => 'https://yourapp.com/oauth/google/callback',
'scopes' => ['openid', 'email', 'profile'],
],
'github' => [
'clientId' => env('OAUTH_GITHUB_CLIENT_ID'),
'clientSecret' => env('OAUTH_GITHUB_CLIENT_SECRET'),
'redirectUri' => 'https://yourapp.com/oauth/github/callback',
'scopes' => ['user:email'],
// 'allowUnverifiedEmailLink' => true, // see "Account Linking & Verified Email" below
],
'azure' => [
'clientId' => env('OAUTH_AZURE_CLIENT_ID'),
'clientSecret' => env('OAUTH_AZURE_CLIENT_SECRET'),
'redirectUri' => 'https://yourapp.com/oauth/azure/callback',
'tenant' => 'common',
'scopes' => ['openid', 'profile', 'email', 'offline_access', 'User.Read'],
'fields' => ['department', 'jobTitle'], // Extra profile data from Graph API
],
// Generic provider with custom profile resolver:
// 'my_provider' => [
// 'clientId' => env('MY_PROVIDER_CLIENT_ID'),
// 'clientSecret' => env('MY_PROVIDER_CLIENT_SECRET'),
// 'redirectUri' => 'https://yourapp.com/oauth/my_provider/callback',
// 'fields' => ['role', 'team'],
// 'fieldsEndpoint' => 'https://api.example.com/userinfo',
// 'profileResolver' => \App\OAuth\MyCustomResolver::class,
// ],
];
Provider Configuration Keys¶
| Key | Description |
|---|---|
clientId, clientSecret, redirectUri |
Standard OAuth credentials (required) |
scopes |
OAuth scopes to request |
fields |
Extra profile fields to fetch after login |
fieldsEndpoint |
Custom API URL for profile fields (GenericProfileResolver) |
profileResolver |
Custom resolver class (must implement ProfileResolverInterface) |
tenant |
Azure-only: 'common', 'organizations', or tenant GUID |
allowUnverifiedEmailLink |
Opt-in (default unset / false): allow auto-linking to an existing local account even when the provider cannot assert the e-mail is verified |
Account Linking & Verified Email¶
When a social account's e-mail matches an existing local (password) account, the identity is auto-linked only if the provider asserts the e-mail is verified (OIDC email_verified / Google verified_email). Providers that cannot assert verification (e.g. Facebook, GitHub) refuse the merge — OauthManager throws an AuthenticationException carrying lang('Auth.oauthEmailUnverified').
To allow auto-linking for such a provider anyway, opt in per provider:
'github' => [
'clientId' => env('OAUTH_GITHUB_CLIENT_ID'),
'clientSecret' => env('OAUTH_GITHUB_CLIENT_SECRET'),
'redirectUri' => 'https://yourapp.com/oauth/github/callback',
'allowUnverifiedEmailLink' => true, // trust this provider's e-mail
],
Security warning: leave
allowUnverifiedEmailLinkunset unless you fully trust the provider. With it enabled, an attacker who registers a social account using a victim's e-mail address could be auto-linked into — and logged in as — the victim's local account.
For an explicit, user-initiated link (the authenticated user deliberately connects a provider, with no e-mail merge and no verified-email requirement), use the oauth-link route — see Routes and OAuth 2.0 & Social Login.
See OAuth 2.0 & Social Login for full setup instructions, profile resolvers, OAuth events, and the OAuthTokenRepository.
Sessions¶
File:
app/Config/Auth.php
// Web session config (existing)
public array $sessionConfig = [
'field' => 'user',
'allowRemembering' => true,
'rememberCookieName' => 'remember',
'rememberLength' => 30 * DAY,
'trackDeviceSessions' => true,
];
// Per-user concurrent session limit. When > 0, each new login terminates
// the oldest active sessions until at most this many remain.
// Requires sessionConfig.trackDeviceSessions = true.
// 0 = unlimited (default).
public int $maxConcurrentSessions = 0;
See Device Sessions — Concurrent Limit for behaviour and edge cases.
Trusted Devices (2FA bypass)¶
File:
app/Config/AuthSecurity.php
// When > 0, users can tick "Trust this device" during 2FA. Successful
// verifications mark the current device session as trusted for this many
// seconds; subsequent logins from the same device skip the 2FA challenge
// until the timestamp expires.
// 0 = feature disabled (always require 2FA when configured).
public int $trustedDeviceLifetime = 30 * DAY;
See TOTP — Trust This Device for the user flow.
WebAuthn / Passkeys¶
File:
app/Config/AuthSecurity.php
Passwordless login and passkey-as-2FA are opt-in per user behind a single global availability flag. When $webauthnEnabled is false (the default) the feature does not exist — no webauthn routes are registered and every endpoint 404s.
public bool $webauthnEnabled = false; // Global availability flag
public ?string $webauthnRelyingPartyId = null; // null → request host
public string $webauthnRelyingPartyName = 'Daycry Auth';
public array $webauthnAllowedOrigins = []; // [] → derived from base_url()
public string $webauthnUserVerification = 'preferred'; // required | preferred | discouraged
public string $webauthnResidentKey = 'preferred'; // discoverable credential
public string $webauthnAttestationConveyance = 'none'; // none | indirect | direct
public ?string $webauthnAuthenticatorAttachment = null; // null | platform | cross-platform
public int $webauthnTimeout = 60000; // ceremony timeout (ms)
public int $webauthnChallengeTtl = 120; // challenge validity (seconds, single-use)
public int $webauthnMaxCredentialsPerUser = 10; // per-user passkey cap
| Property | Default | Meaning |
|---|---|---|
$webauthnEnabled |
false |
Global availability flag. false ⇒ no routes, endpoints 404 |
$webauthnRelyingPartyId |
null |
The rpId credentials are bound to (anti-phishing). null falls back to the request host |
$webauthnRelyingPartyName |
'Daycry Auth' |
Display name shown in the browser passkey prompt |
$webauthnAllowedOrigins |
[] |
Origins accepted during verification. [] derives from base_url(); add subdomains / native-app origins |
$webauthnUserVerification |
'preferred' |
required | preferred | discouraged. Use required for passwordless |
$webauthnResidentKey |
'preferred' |
Discoverable credential — needed for usernameless login |
$webauthnAttestationConveyance |
'none' |
none | indirect | direct. none is best for privacy |
$webauthnAuthenticatorAttachment |
null |
null (both) | platform | cross-platform |
$webauthnTimeout |
60000 |
Ceremony timeout in milliseconds |
$webauthnChallengeTtl |
120 |
Challenge validity in seconds (single-use) |
$webauthnMaxCredentialsPerUser |
10 |
Per-user passkey cap |
The dedicated table is $tables['webauthn_credentials'] (auth_webauthn_credentials, see Table Names); the auto-gated webauthn route group lives in Routes; the webauthn_setup / webauthn_2fa_verify view keys live in Views; and Webauthn2FA::class is a $actions['login'] option.
Requires the
web-auth/webauthn-lib:^5.3Composer dependency. See WebAuthn / Passkeys for the full reference, enrollment / login ceremonies, and security invariants.
Password Confirmation ("sudo mode")¶
File:
app/Config/AuthSecurity.php
The password-confirm filter forces an already-authenticated user to re-enter their password before reaching sensitive routes (changing email / 2FA settings, generating API tokens, account deletion). The global window is:
// How long (seconds) a password confirmation stays valid before the
// `password-confirm` filter bounces the user to /auth/confirm-password.
// 0 = always require a fresh confirmation on every protected request.
// 3 * HOUR matches Laravel Fortify's default.
public int $passwordConfirmationLifetime = 3 * HOUR;
| Property | Default | Meaning |
|---|---|---|
$passwordConfirmationLifetime |
3 * HOUR |
Global max age (seconds) of a password confirmation accepted by the password-confirm filter. 0 = require a fresh confirmation every time |
Per-Route Override¶
The filter accepts a per-route lifetime argument, password-confirm:<seconds>, which overrides the global value for that route — letting your most sensitive endpoints demand a fresher confirmation:
// app/Config/Routes.php
// Standard sudo mode: honour the global $passwordConfirmationLifetime
$routes->post('account/email', 'Account::changeEmail', ['filter' => 'auth:session,password-confirm']);
// Stricter: require a confirmation no older than 60 seconds for this route
$routes->post('account/delete', 'Account::delete', ['filter' => 'auth:session,password-confirm:60']);
Apply password-confirm after an authentication filter (session / auth) on the same route — it intentionally leaves anonymous requests alone and is not a replacement for authentication.
Compliance & Observability¶
File:
app/Config/AuthSecurity.php
These four settings are independent — enable only the ones you need. See Audit & Compliance for the full reference.
// Recheck the just-verified password against HIBP on every login.
// Sets force_reset = 1 on a hit. Adds 1 outbound HTTPS call per login.
public bool $recheckPwnedOnLogin = false;
// Compare each successful login's IP / User-Agent against the user's last
// 30 days of history. On anomalies fires the `suspicious-login` event +
// audit entry. Wire your own listener for the email/Slack/push delivery.
public bool $suspiciousLoginAlerts = false;
// Number of recent password hashes retained per user. The HistoryValidator
// rejects new passwords matching any retained hash.
// 0 = feature disabled.
public int $passwordHistorySize = 0;
// Force a password reset once `password_changed_at` is older than this.
// Apply the `password-age` filter on protected route groups to enforce.
// 0 = passwords never expire.
public int $passwordMaxAge = 0;
When passwordHistorySize > 0, also extend the validator chain:
public array $passwordValidators = [
\Daycry\Auth\Authentication\Passwords\CompositionValidator::class,
\Daycry\Auth\Authentication\Passwords\NothingPersonalValidator::class,
\Daycry\Auth\Authentication\Passwords\DictionaryValidator::class,
\Daycry\Auth\Authentication\Passwords\HistoryValidator::class,
];
Scheduled Maintenance (auth:purge)¶
Instead of the probabilistic on-login purge ($rememberMePurgeChance, now 0 by default), run the dedicated maintenance command on a schedule (cron / daycry/jobs):
# Purge expired remember-me tokens AND terminated device sessions older than 30 days
php spark auth:purge
# Override the device-session age cutoff (days)
php spark auth:purge --days 7
| Option | Default | Meaning |
|---|---|---|
--days <n> |
30 |
Remove terminated auth_device_sessions rows older than <n> days. Values <= 0 fall back to 30 |
auth:purge (command group: Auth) removes:
- expired remember-me tokens from
auth_remember_tokens(all expired rows, regardless of--days), and - terminated device sessions older than
--daysfromauth_device_sessions.
This is table maintenance only — token expiry is enforced at validation time, so purging never affects which tokens can authenticate.
Common Presets¶
Web Application¶
// app/Config/Auth.php
public bool $allowRegistration = true;
public string $defaultAuthenticator = 'session';
public array $actions = ['register' => null, 'login' => null];
// app/Config/AuthSecurity.php
public bool $allowMagicLinkLogins = true;
public int $userMaxAttempts = 5;
public int $userLockoutTime = 1800; // 30 minutes
API (Stateless)¶
// app/Config/Auth.php
public string $defaultAuthenticator = 'jwt';
public array $authenticationChain = ['jwt', 'access_token'];
// app/Config/AuthSecurity.php
public bool $accessTokenEnabled = true;
public int $jwtRefreshLifetime = 30 * DAY;
public int $recordLoginAttempt = 2;
public int $tokenLastUsedThrottle = 60; // seconds
High-Security (production)¶
// app/Config/Auth.php
public array $actions = ['login' => \Daycry\Auth\Authentication\Actions\Totp2FA::class];
public int $maxConcurrentSessions = 5;
// app/Config/AuthSecurity.php
public int $minimumPasswordLength = 12;
public bool $enableInvalidAttempts = true;
public int $maxAttempts = 5;
public int $timeBlocked = 3600;
public int $userMaxAttempts = 3;
public int $userLockoutTime = 7200; // 2 hours
public bool $permissionCacheEnabled = true;
public int $totpWindow = 1;
public int $trustedDeviceLifetime = 30 * DAY;
public bool $suspiciousLoginAlerts = true;
Compliance (SOC 2 / ISO 27001)¶
// app/Config/AuthSecurity.php
public int $passwordHistorySize = 5; // no reuse of last 5
public int $passwordMaxAge = 90 * DAY; // rotation policy
public bool $recheckPwnedOnLogin = true; // ongoing HIBP check
public bool $suspiciousLoginAlerts = true;
public bool $enableLogs = true;
public int $recordLoginAttempt = AuthSecurity::RECORD_LOGIN_ATTEMPT_ALL;
public array $passwordValidators = [
\Daycry\Auth\Authentication\Passwords\CompositionValidator::class,
\Daycry\Auth\Authentication\Passwords\NothingPersonalValidator::class,
\Daycry\Auth\Authentication\Passwords\DictionaryValidator::class,
\Daycry\Auth\Authentication\Passwords\PwnedValidator::class,
\Daycry\Auth\Authentication\Passwords\HistoryValidator::class,
];
Then enforce rotation on protected routes:
// app/Config/Routes.php
$routes->group('app', ['filter' => 'auth:session,password-age'], static function ($routes) {
$routes->get('/dashboard', 'Dashboard::index');
});
Dynamic Configuration¶
Override methods for runtime-computed values:
class Auth extends BaseAuth
{
public function loginRedirect(): string
{
if (auth()->user()?->inGroup('admin')) {
return site_url('admin/dashboard');
}
return site_url('dashboard');
}
public function __construct()
{
parent::__construct();
// Load secrets from environment
if (isset($_ENV['JWT_SECRET'])) {
// Configure JWT via the jwt library's config
}
}
}
🔗 Next: Authentication — Use each authenticator