Complete guide to Neev's security features and best practices.
Neev provides comprehensive security features:
- Brute force protection
- Password policies
- Login tracking and monitoring
- Session management
- IP geolocation
- Suspicious activity detection
// config/neev.php
'login_throttle' => [
'delay_after' => 3, // Failed attempts before progressive delay kicks in
'max_delay_seconds' => 300, // Maximum delay in seconds (exponential backoff caps here)
],Failed attempts are counted per email + IP address (cached for 1 hour). Once the count reaches delay_after, each subsequent failed attempt triggers an exponentially growing delay:
| Failed Attempts | Behavior |
|---|---|
1 to delay_after - 1 |
Normal login speed |
delay_after and beyond |
Delay of 2^(attempts - delay_after) seconds before the next attempt is allowed |
| Higher counts | Delay keeps doubling, capped at max_delay_seconds (default 300s / 5 minutes) |
While a delay is active, login attempts are rejected with a throttle validation error that includes the seconds remaining. There is no permanent lockout — the delay never exceeds max_delay_seconds. A successful login clears the counter and any pending delay.
// config/neev.php
'log_failed_logins' => false, // Cache-only counting (default)
'log_failed_logins' => true, // Also persist failed attempts to the databaseThrottle counters always live in the cache. When log_failed_logins is true, each failed login is additionally recorded in the login_attempts table (with IP, browser, platform, location, is_success => false).
Cache only (recommended for performance):
- Faster
- Auto-expires
- Lost on cache clear
Database (recommended for compliance):
- Persistent records
- Auditable
- Cleaned up by
neev:clean-login-attempts
// config/neev.php
'password' => [
'required',
'confirmed',
Password::min(8)
->max(72)
->letters()
->mixedCase()
->numbers()
->symbols(),
PasswordHistory::notReused(5),
PasswordUserData::notContain(['name', 'email']),
],Prevents password reuse:
PasswordHistory::notReused(5) // Cannot reuse last 5 passwordsPasswords are stored hashed on the users table, with password history maintained as a JSON column.
Prevents using personal information:
PasswordUserData::notContain(['name', 'email'])Checks that password doesn't contain user's name or email.
// config/neev.php
'password_expiry_days' => 90, // Days before password expires. 0 = disabled.Expiry is measured from the user's password_changed_at timestamp. The user model exposes:
$user->passwordExpiresAt(); // Carbon|null — null when disabled or no timestamp
$user->isPasswordExpired(); // bool
$user->isPasswordExpiringSoon(7); // bool — expiring within the given daysEnforcement ships as middleware, registered under the neev:password-not-expired alias (Ssntpl\Neev\Http\Middleware\EnsurePasswordNotExpired). Apply it to routes that should be blocked once the password expires:
Route::middleware(['neev:api', 'neev:password-not-expired'])->group(function () {
// Protected routes
});When the authenticated user's password has expired:
- API (JSON) requests receive a
403response:{"error": "password_expired", "message": "Your password has expired. Please change your password."} - Web requests are aborted with a
403and the same message
Unauthenticated requests pass through unchanged. Set password_expiry_days to 0 to disable expiry entirely.
Warning: everything in the sections above — and the MFA gate — applies only to password-based login. OAuth/social login (the
oauthproviders list inconfig/neev.php) is a separate, complete authentication path:
- The OAuth callback logs the user in (web) or issues a full access token (API) without checking the user's enrolled MFA methods. A user with active TOTP or email MFA is never prompted for a second factor when signing in via OAuth.
- Accounts created via OAuth have no password, so strength rules, password history, and password expiry never apply to them. Their email is marked verified automatically.
The effective security of an OAuth-enabled account is therefore the security of the linked Google/GitHub/Microsoft/Apple account — your MFA and password policies do not add to it.
If MFA or organization-controlled credentials are a compliance requirement:
- Keep the
oauthlist empty or minimal. Providers not in the list return 404 on both the redirect and callback routes, which fully disables the path. - For per-organization enforcement, use tenant/team SSO with the
neev:ensure-ssomiddleware. It rejects (API) or redirects (web) any authenticated session that was not established via SSO, closing the OAuth side door for that organization. - If MFA must be universal across all login methods, implement an application-level step-up check after login — Neev does not provide one for OAuth sessions.
See Authentication → OAuth / Social Login for details.
For each login attempt:
| Field | Description |
|---|---|
method |
Login method (password, passkey, sso, etc.) |
multi_factor_method |
MFA method used |
ip_address |
User's IP address |
platform |
Operating system |
browser |
Browser name and version |
device |
Device type (Desktop, Mobile, Tablet) |
location |
City, country (via GeoIP) |
is_success |
Whether login succeeded |
is_suspicious |
Flagged as suspicious |
LoginAttempt::Password // password
LoginAttempt::Passkey // passkey
LoginAttempt::MagicAuth // magic auth
LoginAttempt::OAuth // oauth
LoginAttempt::SSO // ssoAPI:
curl -X GET https://yourapp.com/neev/loginAttempts \
-H "Authorization: Bearer {token}"Web:
GET /account/loginAttempts// config/neev.php
'login_history_retention_days' => 30,Clean up old records:
php artisan neev:clean-login-attempts// config/neev.php
'maxmind' => [
'db_path' => 'app/geoip/GeoLite2-City.mmdb',
'edition' => env('MAXMIND_EDITION', 'GeoLite2-City'),
'license_key' => env('MAXMIND_LICENSE_KEY'),
],- Register at MaxMind
- Generate a license key
- Add to
.env:
MAXMIND_LICENSE_KEY=your-license-key- Download database:
php artisan neev:download-geoipuse Ssntpl\Neev\Services\GeoIP;
$geoIP = app(GeoIP::class);
$location = $geoIP->getLocation('8.8.8.8');
// Returns: ['city' => 'Mountain View', 'state' => 'California', 'country' => 'United States', 'country_iso' => 'US', 'latitude' => 37.386, 'longitude' => -122.0838, 'timezone' => 'America/Los_Angeles']Set up a scheduled task:
// app/Console/Kernel.php
$schedule->command('neev:download-geoip')->monthly();View all active sessions:
curl -X GET https://yourapp.com/neev/sessions \
-H "Authorization: Bearer {token}"Response:
{
"data": [
{
"id": 1,
"last_used_at": "2024-01-15T10:00:00Z",
"attempt": {
"ip_address": "192.168.1.1",
"browser": "Chrome 120",
"platform": "macOS",
"location": "San Francisco, CA, US"
}
}
]
}curl -X POST https://yourapp.com/neev/logout \
-H "Authorization: Bearer {token}"curl -X POST https://yourapp.com/neev/logoutAll \
-H "Authorization: Bearer {token}"POST /account/logoutSessions
Content-Type: application/x-www-form-urlencoded
session_id=abc123For full session management, use the database driver:
SESSION_DRIVER=databasephp artisan session:table
php artisan migrate| Type | Description |
|---|---|
login |
Session token after login |
api_token |
API access token |
mfa_jwt |
Short-lived JWT used only for MFA verification (not stored) |
Tokens are hashed before storage. A random 40-character plaintext is generated and the AccessToken model's 'token' => 'hashed' cast hashes it on write; verification uses Hash::check():
$plainTextToken = Str::random(40);
$token = $user->accessTokens()->create([
'token' => $plainTextToken, // Stored hashed via the 'hashed' cast
// ...
]);The plaintext is returned to the client once (as {id}|{plaintext}) and cannot be recovered from the database.
Tokens are formatted as {id}|{token}:
1|abc123def456ghi789jkl012mno345pqr678stu901
// Login tokens (configured in neev.login_token_expiry_minutes)
$token = $user->createLoginToken(config('neev.login_token_expiry_minutes', 1440));
// MFA JWTs (configured in neev.mfa_jwt_expiry_minutes)
$mfaJwtExpiry = config('neev.mfa_jwt_expiry_minutes', 30);
// API tokens (optional expiry)
$token = $user->createApiToken('name', ['read'], 43200); // 30 days$token = $user->createApiToken('name', ['read', 'write', 'delete']);
// Check permissions
if ($token->can('write')) {
// Allowed
}The API middleware provides:
-
Token Extraction:
- Bearer token from the
Authorizationheader, in{id}|{plaintext}format
- Bearer token from the
-
Token Validation:
- Looks up the token by ID and verifies the plaintext against the stored hash (
Hash::check) - Checks expiry — expired tokens are deleted and rejected with
401 - Restricts
mfa_tokentype tokens to the MFA verification endpoints only
- Looks up the token by ID and verifies the plaintext against the stored hash (
-
Account Status:
- Rejects deactivated users with
403("Your account is deactivated.")
- Rejects deactivated users with
-
User Context:
- Sets authenticated user on request (and on the
ContextManager) - Updates
last_used_attimestamp
- Sets authenticated user on request (and on the
Email verification is enforced by a dedicated middleware, registered under the neev:verified-email alias (Ssntpl\Neev\Http\Middleware\EnsureEmailIsVerified):
Route::middleware(['neev:api', 'neev:verified-email'])->group(function () {
// Routes requiring a verified email
});When the authenticated user's email is not verified:
- API (JSON) requests receive a
403response:{"message": "Email not verified."} - Web requests are redirected to the
verification.noticeroute
Unauthenticated requests pass through unchanged.
$codes = $user->generateRecoveryCodes();
// Deletes any existing codes, returns array of plain-text codes
// (count from config('neev.recovery_codes'), default 8; 10-char lowercase alphanumeric)Codes are stored hashed via the RecoveryCode model's 'code' => 'hashed' cast:
$this->recoveryCodes()->create([
'code' => $code, // Hashed on write by the cast
]);Each code is single-use — it is deleted after a successful verification:
case 'recovery':
$code = $this->recoveryCodes->first(function ($recoveryCode) use ($otp) {
return Hash::check($otp, $recoveryCode->code);
});
if ($code) {
$code->delete(); // Single-use: removed after verification
return true;
}
break;The login_attempts table includes a boolean is_suspicious column (default false). Neev stores and exposes this flag but does not currently compute it automatically — flagging logic is left to your application.
- New IP address
- New device/browser
- New location
- Failed MFA attempts
- Multiple simultaneous sessions
// In your application (e.g. a LoggedIn listener)
$recentAttempts = $user->loginAttempts()
->where('is_success', true)
->latest()
->take(10)
->get();
$isSuspicious = !$recentAttempts->contains('ip_address', $attempt->ip_address)
|| !$recentAttempts->contains('location', $attempt->location);
$attempt->update(['is_suspicious' => $isSuspicious]);$user->deactivate(); // Sets active = false
$user->activate(); // Sets active = trueDeactivated users cannot log in.
// Requires password confirmation
$user->delete();All related data is cascade deleted.
Each user has a single email address stored directly on the users table:
$user->email; // User's email addressThe email must be verified before full access is granted:
$user->email_verified_at; // Null if unverified// LoggedIn
use Ssntpl\Neev\Events\LoggedIn;
// LoggedOut
use Ssntpl\Neev\Events\LoggedOut;class SecurityAuditListener
{
public function handleLogin(LoggedIn $event)
{
AuditLog::create([
'user_id' => $event->user->id,
'action' => 'login',
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
public function handleLogout(LoggedOut $event)
{
AuditLog::create([
'user_id' => $event->user->id,
'action' => 'logout',
'ip_address' => request()->ip(),
]);
}
}Recommended headers for your application:
// In middleware
public function handle($request, Closure $next)
{
$response = $next($request);
return $response
->header('X-Frame-Options', 'DENY')
->header('X-Content-Type-Options', 'nosniff')
->header('X-XSS-Protection', '1; mode=block')
->header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
->header('Content-Security-Policy', "default-src 'self'");
}// config/neev.php
'multi_factor_auth' => ['authenticator', 'email'],
'log_failed_logins' => true,
'password_expiry_days' => 90,Apply the enforcement middleware to protected routes:
Route::middleware(['neev:api', 'neev:verified-email', 'neev:password-not-expired'])->group(function () {
// ...
});APP_URL=https://yourapp.comPassword::min(12)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised()// routes/api.php
Route::middleware('throttle:60,1')->group(function () {
// API routes
});Set up alerts for:
- Multiple failed login attempts
- Logins from new locations
- Concurrent sessions from different IPs
- Review login attempt logs
- Check for inactive users
- Audit API token usage
- Verify domain configurations
composer update
php artisan neev:download-geoip- Users can delete their accounts
- Users can export their data
- Email consent for communications
- Data retention policies
- Login tracking and audit logs
- MFA enforcement
- Password policies
- Session management
- Strong authentication required
- Audit logging mandatory
- Session timeouts
- Encryption in transit and at rest
# Clean old login attempts
php artisan neev:clean-login-attempts
# Update GeoIP database
php artisan neev:download-geoip// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
$schedule->command('neev:clean-login-attempts')->daily();
$schedule->command('neev:download-geoip')->monthly();
}