Authentication is the most-written and least-differentiated code in the industry. Every application needs it, every implementation looks roughly the same, and the differences between a good one and a bad one are almost entirely in details that are invisible until they're catastrophic.
Tiknix ships one. Not a tutorial, not a scaffold you finish — a working system in
controls/Auth.php that has been exercised across the framework's own admin panel,
member area, and API surface. This article walks through what it actually does, because the
point of inheriting an auth system is being able to inspect it.
What's included
- Registration at
/auth/register— username, email, password, active immediately, auto-login, no verification email required. - Login at
/auth/login— accepts a username or an email in the same field. - Logout that actually destroys the session and expires the cookie.
- Password reset — token-issuing forgot flow plus a reset form, with mail delivery via
lib/Mailer.php. - Google OAuth 2.0 — one-click sign-in, implemented as a drop-in plugin at
lib/plugins/GoogleAuth.php. - Two-factor auth — TOTP, covered in its own article.
- Rate limiting on login, registration, and password reset.
- CSRF protection via a session token and a
csrf_field()view helper. - An admin toggle for whether public self-registration is open at all.
The login path, step by step
Reading the real flow is more informative than a feature list:
- Rate limit first. Five attempts per five minutes. Exceeded, the user gets a retry-after message rather than another guess. The check happens before any database lookup, so a flood costs almost nothing to reject.
- Look up an active member by username or email, with the status condition in the query rather than checked afterward — a disabled account cannot log in even if the password is right.
- Handle OAuth-only accounts explicitly. A member who registered via Google has no password hash. Rather than failing with a confusing "invalid credentials," they are told to use the Google button.
-
Verify with
password_verify()against apassword_hash()digest created withPASSWORD_DEFAULT. Timing-safe comparison, algorithm chosen by the runtime, upgradeable as PHP's default improves. -
Record the login —
lastLoginandloginCount— and clear the rate limiter on success, so a legitimate user who fumbled twice is not penalized. - Branch to 2FA if required, holding only a pending member id in the session. The full session is not established until the second factor passes.
- Establish the session and redirect to the originally requested URL.
Two details worth calling out. Failed logins produce an identical user-facing message whether the account exists or the password was wrong — no user enumeration through the login form. And the distinction is preserved in the log, where operators need it, with different warning messages for "user not found" and "wrong password."
Passwords
Registration requires a username of at least 3 characters and a password of at least 8. Password
reset applies the same 8-character floor. Storage is password_hash($password, PASSWORD_DEFAULT)
throughout — no bespoke hashing, no pepper, no home-grown key stretching, no MD5 lurking in a
legacy branch.
That is a deliberately conservative baseline rather than a complete policy. Length is the only
strength requirement enforced, and there is no breach-list check. If your threat model needs
more, add it in one place — the validation block in register() and
reset().
Rate limiting, and its real boundary
lib/RateLimiter.php is a small, readable session-backed limiter with a clean API:
if (!RateLimiter::check('login', 5, 300)) {
$minutes = ceil(RateLimiter::retryAfter('login', 300) / 60);
// ...refuse, tell the user when to come back
}
RateLimiter::clear('login'); // on success
Current limits:
| Action | Attempts | Window |
|---|---|---|
| Login | 5 | 5 minutes |
| Registration | 5 | 1 hour |
| Forgot password | 3 | 15 minutes |
Be precise about what this protects against. Session-backed limiting stops an ordinary attacker hammering a form in a browser or a naive script that keeps cookies. It does not stop a distributed attempt from many clients that each start a fresh session. For that you want limits at the edge — a reverse proxy, a WAF, or fail2ban on the access log. The in-app limiter is a useful first layer, not the last one.
CSRF
lib/SimpleCsrf.php takes the simple road: one 32-byte random token per session,
valid for every form, no per-form or per-URI tokens. In a view:
<form method="post" action="/projects/save">
<?= csrf_field() ?>
...
</form>
And for fetch-based requests, send csrf_token() as the X-CSRF-TOKEN header.
Per-form tokens are marginally stronger against certain replay scenarios. Per-session tokens are dramatically more likely to be applied consistently across every form in the app — including the ones an agent writes at 3am. That trade is the right one, but it is a trade, and worth knowing you made it.
OAuth as a plugin, not a fork
Google sign-in lives in lib/plugins/GoogleAuth.php. It is a drop-in: configure
credentials, and the button appears in the login view. Nothing in the core login path was
restructured to accommodate it — the OAuth-account branch in login() is three lines.
That is the shape to copy when you add a second provider. An identity provider should be a file you add, not a refactor you perform.
Sessions
The session hardening is applied in code, at startup, not left to a config block you might forget. On boot, Tiknix sets:
session.use_only_cookies = 1
session.use_strict_mode = 1 ; reject attacker-supplied session ids
session.cookie_httponly = 1 ; JS can't read the cookie
session.cookie_samesite = 'Lax'
// and, only when environment = production:
session.cookie_secure = 1 ; HTTPS-only cookie
The important design point: the secure-cookie flag is driven by environment,
not by a standalone secure setting. Set [app] environment = production
— which you do for any real deployment — and the cookie is marked Secure automatically. In a
non-production environment it is left off so local HTTP development works. There is no separate
switch to remember to flip.
On login, the exported member row goes into $_SESSION['member']; on logout, the
session array is cleared, the cookie is expired with a past date, and the session is destroyed
before a fresh one starts for the flash message. That last part is a detail a lot of hand-rolled
logouts get wrong.
- Secure cookies follow
environment. There is nosecure = falsedefault to trip over — the flag is on wheneverenvironment = productionand off otherwise. The one thing to get right is therefore the same thing you already have to get right for debug output and everything else: setenvironment = productionon real deployments. If you never do local HTTP development, it is on everywhere that matters. - No email verification by default. Accounts are active immediately — great for internal tools and friction-sensitive signups, wrong for anything where an unverified email address can cause harm. The
Maileris already wired; adding a verification step is a bounded piece of work. - The session is a snapshot.
$_SESSION['member']is the member row as of login. Change someone's level and their current session may keep the old one until they log in again. For privilege changes that must take effect immediately, re-read the member or terminate the session. - Rate limiting is session-scoped. See above — pair it with edge limiting for anything internet-facing.
- Password policy is length-only. No complexity rules, no breach-list lookup, no rotation. Deliberate, defensible, and yours to extend.
- The installer sets your admin password — it does not ship
admin123. Runninginstall.shprompts you for an admin password (or generates a random one in--automode) and writes that hash to the database. The well-knownadmin123default only exists if you bypass the installer and rundatabase/init.phpby hand; if you take that path, change it immediately. The normal install experience never exposes it.