๐ Authentication โ Complete Guide๏
Daycry Auth supports multiple authentication methods. This guide explains how to use each one, including all security features added in recent versions.
๐ Index๏
Session Authenticator๏
Best for: Traditional web applications using server-side sessions.
Basic Usage๏
// Login attempt
$credentials = [
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
];
$result = auth('session')->attempt($credentials);
if ($result->isOK()) {
return redirect()->to('/dashboard');
}
return redirect()->back()->with('error', $result->reason());
Helper Functions๏
// Check if authenticated
if (auth()->loggedIn()) { ... }
// Get current user
$user = auth()->user();
// Programmatic login (skip credential check)
auth()->login($user);
auth()->login($user, remember: true); // With "remember me"
// Login by user ID
auth()->loginById(42);
// Check credentials without logging in
$result = auth()->check($credentials);
if ($result->isOK()) {
$user = $result->extraInfo(); // The matched User object
}
// Logout
auth()->logout();
Session Configuration๏
// app/Config/Auth.php
public array $sessionConfig = [
'field' => 'user', // Key stored in $_SESSION
'allowRemembering' => true, // Enable "remember me" cookie
'rememberCookieName' => 'remember',
'rememberLength' => 30 * DAY,
'trackDeviceSessions' => false, // See Device Sessions guide
];
Remember Me๏
When a user logs in with remember: true, a long-lived cookie is set. On future visits, even after the session expires, the user is automatically recognized and logged back in.
$remember = (bool) $this->request->getPost('remember');
auth()->attempt($credentials, $remember);
Per-User Account Lockout๏
After a configurable number of failed password attempts, the userโs account is temporarily locked. This is independent of IP-based blocking.
Configuration๏
// app/Config/Auth.php
public int $userMaxAttempts = 5; // Attempts before lockout (0 = disabled)
public int $userLockoutTime = 3600; // Lockout duration in seconds
How It Works๏
Wrong password โ
failed_login_countincrementsReaches
userMaxAttemptsโlocked_untilis set on the user recordAny further login attempt before
locked_untilโ error with minutes remainingLockout expires โ counter resets automatically on next attempt
Successful login โ counter always resets to 0
Unlocking Manually (Admin)๏
model(\Daycry\Auth\Models\UserModel::class)->update($userId, [
'failed_login_count' => 0,
'locked_until' => null,
]);
Concurrency safety๏
recordFailedAttempt() uses an atomic SQL increment expression rather than a read-modify-write pattern, so concurrent failed-login attempts cannot race past userMaxAttempts before the lockout fires.
Compromised-Password Recheck on Login๏
Optional opt-in: after a successful password verification, re-test the live password against HaveIBeenPwned and flag the account for forced reset if it appears in a known breach corpus.
// app/Config/AuthSecurity.php
public bool $recheckPwnedOnLogin = true;
// HIBP timeouts
public float $pwnedPasswordsConnectTimeout = 1.0;
public float $pwnedPasswordsTimeout = 3.0;
What happens on a hit๏
Login proceeds and the session is created normally โ the user is not kicked out mid-flow.
The userโs
email_passwordidentity is markedforce_reset = 1.On their next request the
force-resetfilter (or your equivalent) bounces them through the force-password-reset flow.
What happens on HIBP failure๏
The recheck is wrapped in try/catch. Timeouts, network errors, and 5xx responses are logged at warning level and login proceeds normally โ the recheck never blocks login.
See Audit & Compliance โ Compromised-Password Recheck for the full reference.
Access Token Authenticator๏
Best for: REST APIs, mobile apps, machine-to-machine integrations.
Enable Access Tokens๏
// app/Config/Auth.php
public bool $accessTokenEnabled = true;
// Unused token lifetime
public int $unusedAccessTokenLifetime = YEAR;
// Throttle `last_used_at` writes โ at most one DB UPDATE per token per N
// seconds even when the same token is used for thousands of requests.
// 0 = always write (the legacy behaviour).
public int $tokenLastUsedThrottle = 60;
// Header name (in app/Config/Auth.php)
public array $authenticatorHeader = [
'access_token' => 'X-API-KEY',
];
Generate a Token๏
// Typically done in a login controller after verifying credentials
$token = auth()->user()->generateAccessToken('mobile-app');
return $this->response->setJSON([
'token' => $token->raw_token, // Return the raw token ONCE โ it's never stored in plaintext
'token_type' => 'Bearer',
]);
Use the Token in Requests๏
GET /api/users HTTP/1.1
X-API-KEY: your_raw_token_here
Or via query string:
GET /api/users?token=your_raw_token_here
Protect Routes with the Token Filter๏
// app/Config/Routes.php
$routes->group('api', ['filter' => 'tokens'], static function ($routes) {
$routes->get('users', 'API\UsersController::index');
$routes->get('profile', 'API\ProfileController::show');
});
Token Management๏
$user = auth()->user();
// Generate with name and optional scopes
$token = $user->generateAccessToken('dashboard', ['posts.read', 'posts.write']);
// List all tokens
$tokens = $user->accessTokens();
// Revoke a specific token
$user->revokeAccessToken($token);
// Revoke all tokens (e.g., on password change)
$user->revokeAllAccessTokens();
// Check permission scope on the current token
$currentToken = auth('access_token')->user()->currentAccessToken();
if ($currentToken->can('posts.write')) { ... }
Soft Revocation๏
Tokens can be soft-revoked (marked with a revoked_at timestamp) without being deleted:
use Daycry\Auth\Models\AccessTokenRepository;
use Daycry\Auth\Models\UserIdentityModel;
$repo = new AccessTokenRepository(model(UserIdentityModel::class));
// By raw token
$repo->softRevokeAccessToken($user, $rawToken);
// All tokens for a user (e.g. on password change)
$repo->softRevokeAllAccessTokens($user);
Each soft-revocation writes an EVENT_TOKEN_REVOKED entry to the audit log automatically. Soft-revoked tokens are excluded from all lookups via the revoked_at IS NULL filter, but remain in the database for audit trail.
Scope Enforcement๏
Personal access tokens carry a list of scopes (stored in the extra column, mapped via the scopes datamap). The token-scope: filter validates them on a per-route basis:
$routes->get('api/posts', 'Posts::index', ['filter' => 'tokens,token-scope:posts.read']);
$routes->post('api/posts', 'Posts::create', ['filter' => 'tokens,token-scope:posts.read,posts.write']);
The * wildcard scope satisfies any check. See Filters โ Token Scope Filter for details.
Admin CLI๏
Bulk-revoke all tokens for a user (useful on password compromise / staff offboarding):
php spark auth:tokens revoke -e alice@example.com
php spark auth:tokens revoke -e alice@example.com --type=access_token
See CLI โ auth:tokens for the full reference.
JWT Authenticator๏
Best for: Stateless APIs, microservices, single-page applications.
Configuration๏
JWT configuration is managed by the daycry/jwt library. In app/Config/Auth.php:
use Daycry\Auth\Authentication\JWT\Adapters\DaycryJWTAdapter;
public string $jwtAdapter = DaycryJWTAdapter::class;
Configure the JWT library in app/Config/JWT.php:
public string $algorithmUsed = 'HS256';
public string $secretKey = 'your-secret-key'; // Use env('JWT_SECRET') in production
public string $issuer = 'your-app';
public string $audience = 'your-users';
public int $timeToLive = HOUR; // Access token TTL
public int $allowedClockSkew = 60; // Tolerance in seconds
Use the JWT Filter๏
// app/Config/Routes.php
$routes->group('api', ['filter' => 'jwt'], static function ($routes) {
$routes->get('profile', 'API\ProfileController::show');
$routes->get('posts', 'API\PostsController::index');
});
JWT Refresh Tokens๏
The built-in JwtController provides stateless login with automatic refresh token rotation. Each refresh token is one-time use โ a new one is issued with every refresh.
Register the JWT Routes๏
// app/Config/Routes.php
$routes->post('auth/jwt/login', 'Daycry\Auth\Controllers\JwtController::login', ['as' => 'jwt-login']);
$routes->post('auth/jwt/refresh', 'Daycry\Auth\Controllers\JwtController::refresh', ['as' => 'jwt-refresh']);
$routes->post('auth/jwt/logout', 'Daycry\Auth\Controllers\JwtController::logout', ['as' => 'jwt-logout']);
Or use the routes from app/Config/Auth.php:
// Already included in the 'jwt' route group
Configure Refresh Token Lifetime๏
// app/Config/Auth.php
public int $jwtRefreshLifetime = 30 * DAY; // Refresh token validity
Login๏
POST /auth/jwt/login
Content-Type: application/x-www-form-urlencoded
email=user@example.com&password=secret
Response:
{
"access_token": "eyJ0eXAiOiJKV1Qi...",
"refresh_token": "a3f8c2d1e4b7...",
"user_id": 42,
"token_type": "Bearer"
}
Refresh an Expired Access Token๏
POST /auth/jwt/refresh
Content-Type: application/x-www-form-urlencoded
user_id=42&refresh_token=a3f8c2d1e4b7...
Response:
{
"access_token": "eyJ0eXAiOiJKV1Qi...",
"refresh_token": "newrefreshtoken...",
"user_id": 42,
"token_type": "Bearer"
}
The old refresh token is immediately revoked. Store the new one.
Logout (Revoke Refresh Token)๏
POST /auth/jwt/logout
Content-Type: application/x-www-form-urlencoded
user_id=42&refresh_token=a3f8c2d1e4b7...
Client-Side Flow (JavaScript example)๏
// On 401: try refreshing before giving up
async function apiFetch(url, options = {}) {
let response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
},
});
if (response.status === 401) {
// Try to refresh
const refreshResponse = await fetch('/auth/jwt/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
user_id: localStorage.getItem('user_id'),
refresh_token: localStorage.getItem('refresh_token'),
}),
});
if (refreshResponse.ok) {
const data = await refreshResponse.json();
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
// Retry original request with new token
response = await apiFetch(url, options);
} else {
// Redirect to login
window.location.href = '/login';
}
}
return response;
}
Magic Link Authentication๏
Best for: Passwordless login, reducing friction for new users.
Enable Magic Links๏
// app/Config/Auth.php
public bool $allowMagicLinkLogins = true;
public int $magicLinkLifetime = HOUR;
Complete Flow๏
User enters email on
/login/magic-linkSystem generates a one-time token and emails a link
User clicks the link within 1 hour
Token is verified โ session created โ user redirected
Routes๏
$routes->get('login/magic-link', 'MagicLinkController::loginView', ['as' => 'magic-link']);
$routes->post('login/magic-link', 'MagicLinkController::loginAction');
$routes->get('login/verify-magic-link', 'MagicLinkController::verify', ['as' => 'verify-magic-link']);
Guest Authenticator๏
Best for: Routes that work for both authenticated users and anonymous visitors.
// Returns null if not logged in โ never fails or redirects
$user = auth('guest')->user();
if ($user !== null) {
echo "Hello, {$user->email}!";
} else {
echo "Hello, guest!";
}
Password Reset๏
Users who have forgotten their password can request a reset link by email.
How It Works๏
User visits
/password-resetand enters their emailIf the email matches an account, a secure token is emailed
User clicks the link โ sees a โSet new passwordโ form
On success,
Events::trigger('passwordReset', $user)fires
Routes (already in Config/Auth.php)๏
// GET /password-reset โ requestView()
// POST /password-reset โ requestAction()
// GET /password-reset/message โ messageView()
// GET /password-reset/{token} โ resetView()
// POST /password-reset/{token} โ resetAction()
Configuration๏
// app/Config/Auth.php
public int $passwordResetLifetime = HOUR; // Token validity
Listen for Reset Completion๏
// app/Config/Events.php
Events::on('passwordReset', static function (object $user): void {
// Revoke all access tokens for security
$user->revokeAllAccessTokens();
log_message('notice', "Password reset for {$user->email}");
});
Force Password Reset๏
Administrators can flag user accounts to require a password change on next login.
Flag a User for Password Reset๏
// Force a specific user to reset their password
$user = model(\Daycry\Auth\Models\UserModel::class)->find($userId);
model(\Daycry\Auth\Models\UserIdentityModel::class)
->forceMultiplePasswordReset([$userId]);
// Force ALL users (e.g., after a security breach)
model(\Daycry\Auth\Models\UserIdentityModel::class)
->forceGlobalPasswordReset();
How It Works๏
Once flagged, the ForcePasswordResetFilter intercepts any request from that user and redirects them to /force-reset. After a successful password change, the flag is cleared and they proceed normally.
Apply the Filter๏
// app/Config/Filters.php
public array $aliases = [
'force-reset' => \Daycry\Auth\Filters\ForcePasswordResetFilter::class,
];
// app/Config/Routes.php โ apply to all authenticated routes
$routes->group('dashboard', ['filter' => 'session,force-reset'], static function ($routes) {
$routes->get('/', 'Dashboard::index');
});
Pre-Authentication Events๏
Two events fire before any database check occurs, letting you inspect or log the incoming data:
// app/Config/Events.php
use CodeIgniter\Events\Events;
// Fires before credentials are checked during login
Events::on('pre-login', static function (array $credentials): void {
log_message('debug', 'Login attempt: ' . ($credentials['email'] ?? '?'));
});
// Fires before registration is processed
Events::on('pre-register', static function (array $postData): void {
log_message('debug', 'Registration attempt: ' . ($postData['email'] ?? '?'));
});
See Logging & Monitoring for more event examples.
Switching Between Authenticators๏
Multiple Authenticators in One App๏
// Web users use sessions
$routes->group('dashboard', ['filter' => 'session'], ...);
// API clients use JWT
$routes->group('api', ['filter' => 'jwt'], ...);
// Try all methods in order (chain filter)
$routes->group('flexible', ['filter' => 'chain'], ...);
Chain Authenticator๏
The chain filter tries authenticators in order (configured in $authenticationChain) and stops at the first successful one:
// app/Config/Auth.php
public array $authenticationChain = ['session', 'access_token', 'jwt'];
Runtime Detection๏
if ($this->request->hasHeader('Authorization')) {
$user = auth('jwt')->user();
} elseif ($this->request->hasHeader('X-API-KEY')) {
$user = auth('access_token')->user();
} else {
$user = auth('session')->user();
}
Custom Authenticators๏
Implement AuthenticatorInterface and use the instance() factory method for dependency injection:
<?php
namespace App\Authentication\Authenticators;
use Daycry\Auth\Authentication\Authenticators\Base;
use Daycry\Auth\Interfaces\AuthenticatorInterface;
use Daycry\Auth\Models\UserModel;
use Daycry\Auth\Models\UserIdentityModel;
use Daycry\Auth\Models\LoginModel;
class ApiKeyAuthenticator extends Base implements AuthenticatorInterface
{
public const ID_TYPE_APIKEY = 'apikey';
public static function instance(UserModel $provider): static
{
return new static(
$provider,
service('request'),
model(UserIdentityModel::class),
model(LoginModel::class),
);
}
// ... implement abstract methods from Base
}
Register it:
// app/Config/Auth.php
public array $authenticators = [
// ... existing
'apikey' => \App\Authentication\Authenticators\ApiKeyAuthenticator::class,
];
Why HTTP Digest Auth is not supported๏
daycry/auth implements Basic Auth (BasicAuthFilter) but not HTTP Digest Auth (RFC 2617 / RFC 7616). This is a deliberate architectural decision, not an oversight. The reasons:
1. Incompatible with bcrypt / argon2๏
Digest requires the server to compute HA1 = MD5(username:realm:password) on every challenge. That requires the server to know the password (or a value derived from it).
This package stores passwords with bcrypt (or argon2), a one-way hash. Once hashed, the original password is unrecoverable, so HA1 cannot be computed at validation time.
Supporting Digest would force one of:
Storing HA1 alongside the bcrypt hash in a new column. HA1 is
MD5without salt โ effectively plaintext-equivalent for offline attacks. A database leak would expose Digest-authenticatable credentials, even though the bcrypt hash protects the real password.Tying HA1 to the configured
realm. ChangingAuth.restRealmlater would invalidate every stored HA1, forcing all users to re-enter their plaintext password.Breaking backward compatibility: existing users could not use Digest until they logged in with their plaintext password again so the server could compute their HA1.
2. Effectively deprecated๏
RFC 7616 (2015) explicitly marks MD5-Digest as inadequate for production and adds SHA-256, but the ecosystem has not migrated. Modern HTTP clients, browsers and API consumers do not request Digest by default.
3. No security gain over Basic + TLS๏
With TLS: the
Authorization: Basic ...header is encrypted in transit, so Basic is safe. Digest adds nothing.Without TLS: neither scheme is safe. Digest is still vulnerable to downgrade attacks, does not protect the request body, and leaks the username in the clear.
If you are running this library in production you already need TLS โ and once you have TLS, Basic + Bearer/JWT covers the same ground without the architectural cost.
4. Modern alternatives are already in the package๏
Need |
Use |
|---|---|
Server-to-server API auth |
|
Web login with sessions |
|
Social login |
|
Passwordless |
|
HTTP-level basic auth |
|
If your use case really requires Digest๏
Rare cases (legacy embedded clients, specific compliance requirements, etc.):
Prefer changing the client. Migrate to Bearer / JWT / Basic+TLS if you can.
If you cannot, build a separate package on top of
daycry/auth:Migration: new table
users_digest_ha1 (user_id, realm, ha1, created_at).Endpoint:
POST /digest/enablethat accepts the userโs plaintext password, computes HA1 server-side, and stores it. Per-user opt-in only.Filter: a custom
DigestAuthFilterthat resolves credentials via that table, with a server-side nonce store (e.g.Services::cache(), TTL ~5 min) and replay protection via thenc(nonce-count) parameter.Authenticator: extend
Basewith acheck()that compares the requestresponsefield againstMD5(HA1:nonce:nc:cnonce:qop:HA2).Support at minimum
qop=authand SHA-256 in addition to MD5.Document the database leakage risk (HA1 is not a strong hash).
The decision not to merge this into daycry/auth core is intentional โ keeping HA1 out of the canonical user table is a security boundary worth preserving.
๐ See also:
Filters โ Protect routes with authentication filters
Controllers โ Password reset and force reset controllers
TOTP 2FA โ Time-based one-time passwords
Device Sessions โ Track active logins
Logging & Monitoring โ Events and logs