Web-dev

PHP Authentication Without a Framework — Session-Based Login the Right Way

Published 2026-07-22 ~433 words Tags: php, authentication, security, sqlite

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:

  1. Register — hash a password, store user data
  2. Login — verify credentials, start a session
  3. 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.