🛡️ Audit Log & Compliance¶
This guide covers Daycry Auth's compliance and observability features:
- Granular audit log — separate event-level table for sensitive account changes
- Suspicious login detection — IP / device anomaly alerts
- Token fingerprints & hashed-at-rest tokens — never store raw bearer / link tokens
- Remember-me theft detection — selector/validator mismatch triggers a full purge + alert
- JWT access-token revocation —
token_versioninvalidates every outstanding token at once - Refresh-token revoke reasons — rotation vs logout recorded in the audit log
- Compromised-password recheck on login — opt-in HIBP recheck
- Password history — prevent reuse of recent passwords
- Password rotation policy — force resets after a configurable age
- GDPR helpers — data export and account anonymization
These features are independent — enable only the ones you need.
📋 Index¶
- Audit Log
- Token Fingerprints in the Login Log
- Hashed-at-Rest Tokens (Magic Link & Password Reset)
- Suspicious Login Detection
- Remember-Me Theft Detection
- JWT Access-Token Revocation (
token_version) - Refresh-Token Revoke Reasons
- Compromised-Password Recheck on Login
- Password History (No Reuse)
- Password Rotation Policy
- GDPR Export & Anonymization
- Scheduled Cleanup (
auth:purge) - Quick Configuration Reference
Audit Log¶
A second log table — auth_audit_logs — captures account-level events that need long-term traceability, separate from auth_logs (request-level activity) and auth_logins (login attempts).
What gets recorded¶
Every event written by \Daycry\Auth\Services\AuditLogger lands in auth_audit_logs with:
| Column | Description |
|---|---|
user_id |
The user the event affects (null for system-level events). |
actor_id |
The user that triggered the event (admin or self). Defaults to user_id. |
event_type |
One of the EVENT_* constants on AuditLogger. |
ip_address |
Resolved from the active IncomingRequest (null in CLI). |
user_agent |
Resolved from the active IncomingRequest. |
metadata |
Free-form JSON with extra context. |
created_at |
Datetime stamp. |
Built-in events¶
AuditLogger defines the canonical event identifiers — use these constants instead of free-form strings:
| Constant | When it fires |
|---|---|
EVENT_TOTP_ENABLED |
HasTotp::confirmTotp() succeeds |
EVENT_TOTP_DISABLED |
HasTotp::disableTotp() |
EVENT_TOTP_ADMIN_RESET |
auth:totp reset CLI command |
EVENT_PASSWORD_CHANGED |
Password change via ForcePasswordResetController |
EVENT_PASSWORD_RESET |
Password reset via PasswordResetController |
EVENT_USER_LOCKED |
UserLockoutManager triggers a lockout |
EVENT_USER_UNLOCKED |
Lockout expires automatically |
EVENT_GROUP_ASSIGNED / EVENT_GROUP_REVOKED |
Authorizable::addGroup() / removeGroup() |
EVENT_PERMISSION_GRANTED / EVENT_PERMISSION_REVOKED |
Authorizable::addPermission() / removePermission() |
EVENT_TOKEN_REVOKED |
AccessTokenRepository::softRevokeAccessToken() / softRevokeAllAccessTokens() |
EVENT_REFRESH_TOKEN_REVOKED |
JwtTokenRepository::softRevokeRefreshToken() — fired on JwtController::logout() and on refresh rotation (the consumed token is one-time-use); metadata.reason is rotation or logout |
EVENT_TRUSTED_DEVICE_ADDED |
User ticks "Trust this device" on 2FA |
EVENT_SUSPICIOUS_LOGIN |
SuspiciousLoginDetector flags a login, or RememberMe detects a remember-me token theft (metadata.reason = remember_me_validator_mismatch) |
EVENT_USER_ANONYMIZED |
auth:gdpr anonymize CLI command |
EVENT_OAUTH_LINKED / EVENT_OAUTH_UNLINKED |
Reserved for application use |
EVENT_EMAIL_CHANGE_REQUEST / EVENT_EMAIL_CHANGED |
Reserved for application use |
EVENT_TRUSTED_DEVICE_REMOVED |
Reserved for application use |
Recording your own events¶
AuditLogger::record() is the single entry point — failures are logged at warning level but never propagate, so audit failure cannot break the user-facing flow:
use Daycry\Auth\Services\AuditLogger;
(new AuditLogger())->record(
AuditLogger::EVENT_PASSWORD_CHANGED,
userId: $user->id,
metadata: ['source' => 'profile_form'],
actorId: auth()->id() // optional — defaults to $userId
);
Querying the audit log¶
Use AuditLogModel directly or via auth:audit:
use Daycry\Auth\Models\AuditLogModel;
/** @var AuditLogModel $audit */
$audit = model(AuditLogModel::class);
// Last 50 events for a user
$entries = $audit->recentForUser($userId, 50);
// All TOTP-enabled events in the last 30 days
$entries = $audit->recentByType(
AuditLogger::EVENT_TOTP_ENABLED,
\CodeIgniter\I18n\Time::now()->subDays(30),
);
// Each entry's metadata is decoded automatically
foreach ($entries as $entry) {
echo $entry->event_type . ' at ' . $entry->created_at . "\n";
var_dump($entry->getMetadata());
}
CLI: auth:audit¶
# Last 7 days, 100 rows max (default)
php spark auth:audit
# Last 24 hours
php spark auth:audit --since=24h
# Filter by user
php spark auth:audit --user=alice@example.com
# Filter by event
php spark auth:audit --type=totp.enabled --since=30d --limit=200
--since accepts Ns, Nm, Nh, Nd, Nw (e.g. 90m, 2d, 1w).
Database¶
The migration 2026-05-07-000002_create_audit_logs_table.php creates the table with composite indexes on (user_id, created_at) and (event_type, created_at). The table name is configurable via Auth::$tables['audit_logs'] (defaults to auth_audit_logs).
Token Fingerprints in the Login Log¶
The login-attempt log (auth_logins, written by the authenticators' base flow) stores an identifier for every attempt. For the stateless authenticators that identifier is a non-reversible fingerprint, never the raw bearer token.
| Authenticator | auth_logins.identifier stored |
Method |
|---|---|---|
AccessToken |
hash('sha256', $token) of the X-API-KEY value |
AccessToken::getLogCredentials() |
JWT |
hash('sha256', $token) of the Authorization: Bearer value |
JWT::getLogCredentials() |
Session |
the email / username identifier (not a secret) | Session::getLogCredentials() |
Each authenticator implements the abstract Base::getLogCredentials(array $credentials): mixed. The base login flow calls it before writing the auth_logins row, so a leaked or shared log database never yields a usable token — only its fingerprint, which cannot be replayed. An empty token is logged as an empty string ($token === '' ? '' : hash('sha256', $token)).
The same
hash('sha256', $rawToken)fingerprint is whatauth_users_identities.secretstores forACCESS_TOKENandJWT_REFRESHidentities, so the value in the login log can be correlated with the identity row without either ever holding the raw token.
Hashed-at-Rest Tokens (Magic Link & Password Reset)¶
Ephemeral e-mailed tokens — magic-link login and password-reset codes — are now stored SHA-256 hashed at rest. The RAW token is e-mailed to the user; only hash('sha256', $token) is persisted in auth_users_identities.secret.
How it works¶
Daycry\Auth\Libraries\TokenEmailSendergenerates a random token, e-mails the raw value, and persists'secret' => hash('sha256', $token).- Lookups go through
UserIdentityModel::getIdentityBySecret(string $type, ?string $secret), which hashes the looked-up value (->where('secret', hash('sha256', $secret))) before matching — so the raw token from the e-mail link is verified against the stored hash without the raw value ever touching the database. - Single-use + expiry are enforced by the consuming controllers.
A database or backup leak therefore never exposes a directly usable login/reset token.
Upgrade note: because the storage format changed, any unconsumed magic-link / password-reset tokens issued before the upgrade become invalid — affected users simply request a new link. There is no destructive schema change; only the storage format of
auth_users_identities.secretfor these ephemeral identity types changed.The magic-link e-mail view also receives the
$uservariable, so templates can address the recipient (e.g.$user->username).
Suspicious Login Detection¶
After every successful login, SuspiciousLoginDetector compares the new login against the user's recent history. When something looks off, it fires the suspicious-login event and writes an audit entry — your application listens to the event to deliver an email / Slack / push notification.
Enable¶
Detection signals¶
Each login is checked against the user's history (last 30 days). The current implementation flags:
| Flag | Source | Test |
|---|---|---|
new_ip |
auth_logins |
No successful login from this IP in the lookback window |
new_device |
auth_device_sessions |
No prior session row with this exact User-Agent |
The detector is forward-compatible — additional signals (geo-IP, ASN reputation, time-of-day, JA3 fingerprint) can be added without changing the integration point.
React with an event listener¶
// app/Config/Events.php
use CodeIgniter\Events\Events;
use Daycry\Auth\Entities\User;
Events::on('suspicious-login', static function (User $user, array $flags, string $ip, string $ua): void {
helper('email');
$email = emailer()
->setFrom(setting('Email.fromEmail'), (string) setting('Email.fromName'))
->setTo($user->email)
->setSubject(lang('Auth.suspiciousLoginSubject'))
->setMessage(view('Daycry\\Auth\\Views\\Email\\suspicious_login_alert', [
'user' => $user,
'flags' => $flags,
'ipAddress' => $ip,
'userAgent' => $ua,
'date' => \CodeIgniter\I18n\Time::now()->toDateTimeString(),
]));
$email->send(false);
});
The package ships Views/Email/suspicious_login_alert.php as a starting template — copy it to app/Views/ and customize.
What lands in the audit log¶
Every flagged login also writes an EVENT_SUSPICIOUS_LOGIN row with the flags and request context as metadata, so you have a permanent record even if the email fails to send.
Remember-Me Theft Detection¶
Remember-me cookies use the Paragonie split selector/validator design. Daycry\Auth\Authentication\Services\RememberMe enforces two security guarantees on every cookie presented to checkRememberMeToken():
Expiry enforced at validation time¶
An expired remember-me cookie can no longer authenticate. checkRememberMeToken() rejects a token once expires is in the past, regardless of whether the row has been purged from the table yet:
The probabilistic on-login purge is now table maintenance only — never the security control. (See $rememberMePurgeChance below, whose default is now 0, and the scheduled auth:purge command.)
Theft response¶
If the selector matches but the validator does not (hash_equals() fails), the cookie was likely stolen or guessed. RememberMe::handlePossibleTheft() performs a defence-in-depth response:
- Purge all of the user's tokens —
RememberModel::purgeRememberTokensByUserId($userId)invalidates every remember-me token the user holds, forcing a fresh login everywhere. - Write an audit entry —
AuditLogger::record(AuditLogger::EVENT_SUSPICIOUS_LOGIN, $userId, ['reason' => 'remember_me_validator_mismatch', 'selector' => $selector]). - Fire an event —
Events::trigger('remember-me-theft', $userId, $selector)so your application can notify the user.
React with an event listener¶
// app/Config/Events.php
use CodeIgniter\Events\Events;
Events::on('remember-me-theft', static function (int $userId, string $selector): void {
// Notify the user that a persistent-login cookie was misused and all
// remember-me sessions were revoked as a precaution.
log_message('warning', "Remember-me theft suspected for user {$userId} (selector {$selector})");
});
JWT Access-Token Revocation (token_version)¶
JWT access tokens are stateless and self-contained, so they cannot be revoked individually. Instead, every user carries a token_version counter; bumping it invalidates every outstanding access token for that user in one operation ("log out everywhere").
How it works¶
JwtControllermints the access-token payload as{uid, tv}, wheretvis the user's currenttoken_versionat issue time:
- The
JWTauthenticator'scheck()rejects a token whose embeddedtvno longer matches the user's storedtoken_version, returninglang('Auth.revokedToken')("The token has been revoked."):
if ($tokenVersion !== null && (int) ($user->token_version ?? 0) !== $tokenVersion) {
return new Result(['success' => false, 'reason' => lang('Auth.revokedToken')]);
}
- Legacy tokens carrying a bare scalar user id (no
tv) are still accepted — the version check is simply skipped for them.
Revoking¶
User::revokeIssuedTokens(): void bumps token_version atomically (token_version + 1):
It is also called automatically by:
| Trigger | Source |
|---|---|
| Ban | Bannable::ban() |
| Password reset / change | Services\PasswordChangeRecorder::record() |
Database¶
Migration 2026-05-08-000001_add_jwt_token_version_to_users.php adds users.token_version (int, default 0).
Refresh-Token Revoke Reasons¶
JWT refresh tokens are soft-revoked (the revoked_at column), and each revocation records why in the audit log. JwtTokenRepository::softRevokeRefreshToken(int $identityId, ?int $userId = null, ?string $reason = null) writes an EVENT_REFRESH_TOKEN_REVOKED row whose metadata.reason distinguishes the two paths:
metadata.reason |
When | Source |
|---|---|---|
rotation |
A refresh token was exchanged for a new one — refresh is one-time-use, so the consumed token is immediately revoked | JwtController::refresh() |
logout |
Stateless logout soft-revokes the presented refresh token | JwtController::logout() |
JwtController routes refresh / logout / issue through service('jwtTokenRepository'). When $reason is null the audit entry is still written (only identity_id in metadata), but the built-in flows always pass a reason.
Compromised-Password Recheck on Login¶
PwnedValidator is normally only used during registration / password change. Enable recheckPwnedOnLogin to also test the current password against HaveIBeenPwned on every successful login — useful for catching passwords that were safe when the account was created but appeared in a breach later.
Enable¶
// app/Config/AuthSecurity.php
public bool $recheckPwnedOnLogin = true;
// HIBP timeouts (already documented in 02-configuration.md)
public float $pwnedPasswordsConnectTimeout = 1.0;
public float $pwnedPasswordsTimeout = 3.0;
Behaviour¶
- Login proceeds normally; password is verified.
- After verification, the live password is hashed (SHA-1 prefix per HIBP's k-anonymity API) and queried.
- If found in a breach, the user's
email_passwordidentity getsforce_reset = 1— the next request bounces them through the force-reset flow (no immediate logout, so they finish what they were doing first). - If HIBP is unreachable / slow, the failure is logged at
warningand login proceeds — the recheck never blocks login.
Cost¶
One outbound HTTPS call per login when enabled. Tune pwnedPasswordsTimeout (default 3s) based on your latency budget. Disabled by default for that reason.
Password History (No Reuse)¶
Forces users to pick a password that does not match any of their last N hashes. Required for SOC 2 / ISO 27001 environments.
Enable¶
Add the validator to your password 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, // <— add this
];
How it works¶
- On every persisted password change,
PasswordChangeRecorderwrites the previous hash intoauth_password_history(one row per change, indexed onuser_id, created_at). - Older entries beyond the configured retention window are pruned automatically.
- On the next password change,
HistoryValidatorrunspassword_verify()against each retained hash — if any match, the new password is rejected withAuth.passwordHistoryReuse.
Hooks¶
The recorder is wired automatically into:
PasswordResetController::resetAction()— token-based reset.ForcePasswordResetController::resetAction()— current-password gated reset.
For custom password change flows (e.g. an admin-side password set), call:
use Daycry\Auth\Services\PasswordChangeRecorder;
$previousHash = $user->password_hash;
$user->setPassword($newPassword);
$userModel->save($user);
(new PasswordChangeRecorder())->record($user, $previousHash);
Database¶
Migration 2026-05-07-000005_create_password_history_table.php. Table is configurable via Auth::$tables['password_history'].
Password Rotation Policy¶
Forces a password reset once password_changed_at is older than passwordMaxAge seconds.
Enable¶
Wire the filter¶
The password-age alias is registered automatically by Registrar. Apply it alongside auth (or session) on the routes that should enforce rotation:
// app/Config/Routes.php
$routes->group('app', ['filter' => 'auth:session,password-age'], static function ($routes) {
$routes->get('/dashboard', 'Dashboard::index');
});
Behaviour¶
- The filter runs after authentication.
- If
password_changed_atis null (never recorded) the user is left alone — older accounts grandfathered in. - If
password_changed_atis older thanpasswordMaxAge, the user'semail_passwordidentity getsforce_reset = 1and the request redirects toConfig\Auth::forcePasswordResetRedirect()withAuth.passwordExpired.
Database¶
Migration 2026-05-07-000006_add_password_changed_at_to_users.php adds the column. PasswordChangeRecorder (same service used by Password History) stamps it on every persisted password change.
Migrating existing accounts: rows already in
userswill havepassword_changed_at = NULL. Either grandfather them (the filter ignores nulls) or run a one-shotUPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL;if you want to start the rotation clock immediately.
GDPR Export & Anonymization¶
Two CLI subcommands satisfy the most common GDPR data-subject requests.
auth:gdpr export¶
Emits a JSON document covering everything the system knows about a user:
# To stdout
php spark auth:gdpr export -e alice@example.com
# Save to a file (recommended for large histories)
php spark auth:gdpr export -e alice@example.com -o /tmp/alice.json
The payload includes:
user— identity row (id, uuid, username, email, active, lockout state, password rotation timestamp).identities— every row inauth_users_identities, with secrets redacted:- bcrypt password hashes are marked
<redacted: bcrypt hash>(thesecret2column) - access / refresh tokens marked
<redacted: hashed token> - other types (magic link, password reset, OAuth, TOTP secret) emit the stored
secretcolumn verbatim — and these are never the raw value: magic-link / password-reset secrets are SHA-256-hashed at rest, OAuth identities hold provider data, and the TOTP secret is AES-encrypted device_sessions— full history (active + terminated).login_history— last 500 entries fromauth_logins.audit_log— last 500 entries fromauth_audit_logs, with metadata decoded.password_history— count only (the bcrypt hashes themselves are not personal data and exposing them would defeat the purpose of revoking a token).backup_codes— counts ofremaining/used(never the raw codes).
auth:gdpr anonymize¶
Permanent, irreversible — keeps the user row to preserve foreign-key integrity but replaces personal fields with placeholders:
What happens:
- Identities, device sessions, password history, backup codes —
DELETErows for the user. - User row —
usernamebecomesdeleted_<id>,active = 0, lockout / rotation fields cleared. - Audit log — final
EVENT_USER_ANONYMIZEDentry withactor_id = user_idandmetadata.initiator = cli.
The command prompts for confirmation before any destructive action. Login attempts (auth_logins) are kept by default — they are append-only events, not personal data of the deleted user. Adapt to your interpretation of GDPR by extending GdprCommand::anonymizeAction().
When to use export vs anonymize¶
| User asks for | Use |
|---|---|
| "What data do you have on me?" (Article 15 access) | export |
| "Send me my data" (Article 20 portability) | export -o /path/to.json and email the file |
| "Delete me" (Article 17 erasure) | anonymize (preserves FK integrity) or hard auth:user delete (full deletion, may break logs / FK references) |
Scheduled Cleanup (auth:purge)¶
php spark auth:purge (command group Auth) replaces the probabilistic, on-login purge of expired tokens with a deterministic, scheduled job. It removes:
- expired remember-me tokens from
auth_remember_tokens, and - terminated device sessions older than
--days.
# Purge expired remember-me tokens + terminated sessions older than 30 days (default)
php spark auth:purge
# Override the device-session age threshold
php spark auth:purge --days 7
| Option | Default | Meaning |
|---|---|---|
--days <n> |
30 |
Age in days above which terminated device sessions are removed. |
Run it on a schedule (cron or daycry/jobs) rather than relying on the on-login purge. Because remember-me expiry is now enforced at validation time, the only role of the inline purge is table maintenance — and its probability default is now 0 (see $rememberMePurgeChance below).
Quick Configuration Reference¶
All compliance features are opt-in. Here is the full set of new toggles in app/Config/AuthSecurity.php:
namespace Config;
use Daycry\Auth\Config\AuthSecurity as AuthSecurityConfig;
class AuthSecurity extends AuthSecurityConfig
{
// Compromised-password recheck (1 outbound HTTP call per login)
public bool $recheckPwnedOnLogin = true;
// Suspicious login detection (writes audit + fires event)
public bool $suspiciousLoginAlerts = true;
// Password history — last 5 hashes
public int $passwordHistorySize = 5;
// Force password rotation every 90 days
public int $passwordMaxAge = 90 * DAY;
// Inline expired remember-me purge probability. Default is now 0 —
// expiry is enforced at validation time, so schedule `auth:purge`
// instead of paying purge latency on a fraction of logins.
public int $rememberMePurgeChance = 0;
// Add HistoryValidator to the 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,
];
}
And the routing-level wiring in app/Config/Routes.php: