Frameworks are great for big projects, but for smaller apps they add unnecessary complexity. PHP's built-in session handling combined with SQLite gives you everything you need for solid authentication.
The Core Pattern
Authentication in PHP boils down to three operations:
- Register — hash a password, store user data
- Login — verify credentials, start a session
- Check — validate the session on every request
That's it. Everything else (CSRF, rate limiting, email verification) is layered on top.
Database Setup
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
last_login TEXT
);
CREATE INDEX idx_users_email ON users(email);
Registration
function registerUser($db, $email, $password, $name) {
// Validate input
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return 'Invalid email address';
}
if (strlen($password) < 8) {
return 'Password must be at least 8 characters';
}
// Hash password — NEVER use md5 or sha1
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// Insert
$stmt = $db->prepare("INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)");
try {
$stmt->execute([$email, $hash, $name]);
return true;
} catch (PDOException $e) {
if ($e->getCode() == 23000) {
return 'Email already registered';
}
throw $e;
}
}
The PASSWORD_BCRYPT with cost 12 takes about 250ms per hash — fast enough for UX, slow enough to make brute-forcing impractical.
Login
function loginUser($db, $email, $password) {
$stmt = $db->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
return false;
}
// Regenerate session ID to prevent session fixation
session_regenerate_id(true);
// Store only what you need
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['name'];
$_SESSION['logged_in_at'] = time();
// Update last login
$db->prepare("UPDATE users SET last_login = datetime('now') WHERE id = ?")
->execute([$user['id']]);
return true;
}
session_regenerate_id(true) is critical — it prevents session fixation attacks where an attacker sets a known session ID.
Auth Check
function requireAuth() {
if (empty($_SESSION['user_id'])) {
header('Location: /login.php');
exit;
}
}
function getCurrentUser($db) {
if (empty($_SESSION['user_id'])) return null;
$stmt = $db->prepare("SELECT id, email, name, created_at FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
return $stmt->fetch();
}
Never store the password hash in the session — only store the user ID. Fetch fresh data from the database on each request.
Session Security
// Call at the start of every request
function initSession() {
// Use secure cookies
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', 1);
// Limit session lifetime
ini_set('session.gc_maxlifetime', 7200); // 2 hours
session_start();
}
Putting It Together
<?php
require_once 'config.php';
initSession();
$db = getDB();
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'login') {
if (loginUser($db, $_POST['email'], $_POST['password'])) {
header('Location: /dashboard.php');
exit;
}
$error = 'Invalid email or password';
}
}
That's the full pattern. No framework, no dependencies, no composer packages. Just PHP, SQLite, and bcrypt — all built into the language.