PHP PDO Login

A complete, production-ready PHP login system built from scratch - with every security layer explained step by step. No frameworks, no Composer, just clean PHP 8 + PDO.
email: admin@a2zwebhelp.com | Password: password

Most PHP login tutorials on the internet are dangerously incomplete - they skip CSRF protection, use deprecated md5() for passwords, or never handle brute-force attacks. In this tutorial, we're building it the right way from the ground up. By the time you finish, you'll have a login system you'd actually be comfortable deploying to a real project.

We'll use PDO with real prepared statements throughout (no string-concatenated SQL anywhere), Google's latest reCAPTCHA v3 for invisible bot detection, a proper brute-force lockout that tracks failed attempts per email in the database, and a Remember Me cookie system that stores only a hashed token - never the raw value - exactly the way modern frameworks do it.

PHP PDO login

What You'll Build

Here's a quick look at the finished project before we dive into code:

Styled Login Page

Split-panel design with form validation, password toggle, and live reCAPTCHA integration.

auth.php Library

One central file handling sessions, CSRF tokens, reCAPTCHA, lockout logic and Remember Me cookies.

Lockout System

Tracks failed attempts in the DB. 5 failures in 30 minutes = account locked with a countdown.

Protected Dashboard

A sample protected page showing session data, IP, and all active security features at a glance.

Prerequisites

  • PHP 8.0 or higher
  • MySQL 5.7+ or MariaDB
  • A local server: XAMPP, Laragon, or WAMP
  • A free Google reCAPTCHA v3 key pair (we'll set it up in Step 2)
  • Basic knowledge of PHP, HTML, and SQL - no frameworks needed

Project File Structure

The entire system is 8 files. Here's what each one does before we start writing any code:

FileWhat It Does
config.phpCentral settings - DB credentials, reCAPTCHA keys, lockout limits, cookie & session config
db.phpPDO connection function (reused across every file)
auth.phpThe security engine - every feature (CSRF, reCAPTCHA, lockout, Remember Me, session) lives here as functions
login.phpThe login form + all POST handling logic
dashboard.phpA sample protected page - shows how to lock any page behind login
logout.phpDestroys the session and clears the Remember Me cookie
database.sqlCreates the database, 3 tables, and a seeded admin user
README.mdSetup instructions for the download package

Understanding the Security Layers

Before we write a single line of code, it helps to understand what we're building and why each layer matters. Think of this login system as an airport security checkpoint - each layer is a separate gate, and attackers have to bypass all of them to get through.

PDO Prepared Statements

Every database query uses bound parameters. SQL injection is impossible because user input is never concatenated into SQL strings.

CSRF Tokens

A unique random token is embedded in the form and stored in the session. Any forged cross-site request will fail the hash_equals() check.

reCAPTCHA v3

Runs invisibly in the background - no checkbox for real users. Google scores each request 0.0 (bot) to 1.0 (human). We reject anything below 0.5.

Brute-Force Lockout

After 5 failed attempts within 30 minutes, the account is locked. A countdown timer shows the user exactly when they can try again.

Remember Me Cookie

Generates a cryptographically random token. Only the SHA-256 hash is stored in the DB. The token rotates on every use to prevent replay attacks.

Session Hardening

Sessions regenerate on login (fixing session fixation), expire after 30 min of inactivity, and are killed if the IP address changes mid-session.

1Database Setup

We need three tables. The users table stores credentials, login_attempts powers the lockout system, and user_sessions tracks active sessions for multi-device awareness.

database.sql
CREATE DATABASE IF NOT EXISTS secure_login_db
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE secure_login_db;

-- Users table
CREATE TABLE users (
    id             INT AUTO_INCREMENT PRIMARY KEY,
    name           VARCHAR(100)  NOT NULL,
    email          VARCHAR(150)  NOT NULL UNIQUE,
    password       VARCHAR(255)  NOT NULL,        -- bcrypt hash
    remember_token VARCHAR(64)   DEFAULT NULL,    -- hashed cookie token
    token_expires  DATETIME      DEFAULT NULL,
    is_active      TINYINT(1)   NOT NULL DEFAULT 1,
    created_at     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Login attempts (for brute-force lockout)
CREATE TABLE login_attempts (
    id           INT AUTO_INCREMENT PRIMARY KEY,
    email        VARCHAR(150) NOT NULL,
    ip_address   VARCHAR(45)  NOT NULL,
    attempted_at TIMESTAMP    DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Active sessions (optional multi-device tracking)
CREATE TABLE user_sessions (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    user_id       INT          NOT NULL,
    session_token VARCHAR(64)  NOT NULL UNIQUE,
    ip_address    VARCHAR(45)  NOT NULL,
    user_agent    VARCHAR(255) DEFAULT NULL,
    created_at    TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
The seed data in database.sql includes a default admin user with email admin@example.com and password Admin@1234. Change the password immediately after your first login.

2Setting Up Google reCAPTCHA v3

reCAPTCHA v3 is the latest generation - it runs completely invisibly. There's no "I'm not a robot" checkbox for real users. Instead, Google analyzes user behaviour behind the scenes and returns a score between 0.0 (very likely a bot) and 1.0 (very likely human). We reject anything below 0.5.

To get your key pair:

  1. Go to google.com/recaptcha/admin/create
  2. Label: anything (e.g. "My Login System")
  3. Type: choose Score based (v3)
  4. Add your domain (e.g. localhost for local dev)
  5. Submit - you'll get a Site Key (goes in HTML) and a Secret Key (stays on the server)

3config.php - Central Settings

Rather than scattering settings across multiple files, everything lives in config.php. Change your lockout rules, reCAPTCHA threshold, or cookie expiry in one place and it applies everywhere.

config.php
<?php
// ── Database ──────────────────────────────────────────────────
define('DB_HOST',    'localhost');
define('DB_NAME',    'secure_login_db');
define('DB_USER',    'root');
define('DB_PASS',    '');
define('DB_CHARSET', 'utf8mb4');

// ── Site ──────────────────────────────────────────────────────
define('SITE_NAME', 'SecureApp');
define('SITE_URL',  'http://localhost/php-login-system');

// ── Google reCAPTCHA v3 ───────────────────────────────────────
define('RECAPTCHA_SITE_KEY',   'YOUR_SITE_KEY_HERE');
define('RECAPTCHA_SECRET_KEY', 'YOUR_SECRET_KEY_HERE');
define('RECAPTCHA_MIN_SCORE',  0.5);

// ── Login / Lockout ───────────────────────────────────────────
define('MAX_LOGIN_ATTEMPTS', 5);   // Lock after 5 failures
define('LOCKOUT_MINUTES',    30);  // Lock duration

// ── Remember Me Cookie ────────────────────────────────────────
define('COOKIE_NAME', 'remember_me');
define('COOKIE_DAYS', 30);

// ── Session ───────────────────────────────────────────────────
define('SESSION_NAME',    'SECURE_APP_SESSION');
define('SESSION_TIMEOUT', 1800);   // 30 min inactivity

4auth.php - The Security Engine

This is the heart of the system. All security logic lives here as clean, reusable functions. Let's go through each section and understand exactly what it does.

4a - Starting a Secure Session

A regular session_start() leaves you exposed to session fixation attacks and cookies that JavaScript can steal. Our start_secure_session() function locks all of this down:

Secure session start
function start_secure_session(): void
{
    if (session_status() === PHP_SESSION_ACTIVE) return;

    session_name(SESSION_NAME);

    session_set_cookie_params([
        'lifetime' => 0,              // Expires when browser closes
        'path'     => '/',
        'secure'   => isset($_SERVER['HTTPS']), // HTTPS only in production
        'httponly' => true,           // JS cannot read the cookie
        'samesite' => 'Lax',          // Blocks cross-site cookie sending
    ]);

    session_start();

    // Regenerate ID on first start to prevent session fixation
    if (empty($_SESSION['__initiated'])) {
        session_regenerate_id(true);
        $_SESSION['__initiated'] = true;
    }
}

4b - Logging In and Checking Auth

When a user successfully authenticates, login_user() stores their details in the session, regenerates the session ID (preventing session fixation on login), and records their IP for consistency checks:

login_user() & is_logged_in()
function login_user(array $user): void
{
    start_secure_session();
    session_regenerate_id(true);  // New ID on every login

    $_SESSION['user_id']       = $user['id'];
    $_SESSION['user_name']     = $user['name'];
    $_SESSION['user_email']    = $user['email'];
    $_SESSION['user_ip']       = get_client_ip();
    $_SESSION['last_activity'] = time();
    $_SESSION['login_time']    = time();
}

function is_logged_in(): bool
{
    start_secure_session();
    if (empty($_SESSION['user_id'])) return false;

    // Inactivity timeout check
    if (isset($_SESSION['last_activity']) &&
        (time() - $_SESSION['last_activity']) > SESSION_TIMEOUT) {
        logout_user();
        return false;
    }
    $_SESSION['last_activity'] = time();

    // IP consistency check - kills hijacked sessions
    if (isset($_SESSION['user_ip']) &&
        $_SESSION['user_ip'] !== get_client_ip()) {
        logout_user();
        return false;
    }

    return true;
}

4c - CSRF Protection

CSRF (Cross-Site Request Forgery) is when a malicious website tricks a logged-in user's browser into submitting a form on your site without their knowledge. The defence is a secret random token that only your server and the real user's session know:

CSRF functions
// Generate or reuse a token stored in session
function csrf_token(): string
{
    start_secure_session();
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // 64-char token
    }
    return $_SESSION['csrf_token'];
}

// Drop this inside every <form>: <?= csrf_field() ?>
function csrf_field(): string
{
    return '<input type="hidden" name="csrf_token" value="' . csrf_token() . '">';
}

// Validate on POST - hash_equals() prevents timing attacks
function csrf_validate(): bool
{
    $submitted = $_POST['csrf_token'] ?? '';
    $stored    = $_SESSION['csrf_token'] ?? '';
    if (empty($submitted) || empty($stored)) return false;
    return hash_equals($stored, $submitted);
}
Why hash_equals() instead of ===? Regular string comparison in PHP exits as soon as it finds a mismatched character - an attacker can exploit this timing difference to guess the token character by character. hash_equals() always takes the same amount of time regardless of where the strings differ.

4d - reCAPTCHA v3 Verification

The JS loads reCAPTCHA and generates a token when the form is submitted. That token goes to Google's API where it's verified server-side. We never trust the score the browser claims - always verify on your server:

verify_recaptcha()
function verify_recaptcha(string $token): bool
{
    if (empty($token)) return false;

    $data = http_build_query([
        'secret'   => RECAPTCHA_SECRET_KEY,
        'response' => $token,
        'remoteip' => get_client_ip(),
    ]);

    $context  = stream_context_create([
        'http' => ['method' => 'POST',
                   'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
                   'content' => $data, 'timeout' => 5]
    ]);

    $response = @file_get_contents(
        'https://www.google.com/recaptcha/api/siteverify',
        false, $context
    );

    if ($response === false) return false; // Fail closed if API unreachable

    $result = json_decode($response, true);

    // success = true AND score above threshold
    return isset($result['success'], $result['score'])
        && $result['success'] === true
        && $result['score'] >= RECAPTCHA_MIN_SCORE;
}
Fail closed: If the reCAPTCHA API is unreachable (e.g. server outage), our function returns false and blocks the login. This is intentional - it's safer to briefly block a real user than to leave the door open for bots during an API outage.

4e - Brute-Force Lockout

Every failed login records the email and IP into login_attempts with a timestamp. Before each login attempt, we count how many failures happened within the last 30 minutes. If it hits 5, we lock the account and show a countdown:

Lockout functions
function record_failed_attempt(string $email): void
{
    $pdo  = db_connect();
    $stmt = $pdo->prepare(
        "INSERT INTO login_attempts (email, ip_address) VALUES (:email, :ip)"
    );
    $stmt->execute([':email' => $email, ':ip' => get_client_ip()]);
}

function is_locked_out(string $email): bool
{
    $pdo  = db_connect();
    $stmt = $pdo->prepare(
        "SELECT COUNT(*) FROM login_attempts
         WHERE email = :email
           AND attempted_at >= NOW() - INTERVAL :mins MINUTE"
    );
    $stmt->bindValue(':email', $email,          PDO::PARAM_STR);
    $stmt->bindValue(':mins',  LOCKOUT_MINUTES, PDO::PARAM_INT);
    $stmt->execute();
    return (int) $stmt->fetchColumn() >= MAX_LOGIN_ATTEMPTS;
}

function lockout_remaining_minutes(string $email): int
{
    // Finds the earliest failure in the window, calculates time left
    $pdo  = db_connect();
    $stmt = $pdo->prepare(
        "SELECT attempted_at FROM login_attempts
         WHERE email = :email
           AND attempted_at >= NOW() - INTERVAL :mins MINUTE
         ORDER BY attempted_at ASC LIMIT 1"
    );
    $stmt->bindValue(':email', $email,          PDO::PARAM_STR);
    $stmt->bindValue(':mins',  LOCKOUT_MINUTES, PDO::PARAM_INT);
    $stmt->execute();
    $row = $stmt->fetch();
    if (!$row) return 0;
    $unlockAt  = strtotime($row['attempted_at']) + (LOCKOUT_MINUTES * 60);
    return max(0, (int) ceil(($unlockAt - time()) / 60));
}

4f - Remember Me Cookie

The "Remember Me" feature is one of the most commonly implemented insecurely. Storing the user ID alone in a cookie is terrible - anyone who sees the cookie can impersonate that user forever. Our approach:

  1. Generate a 64-character random token using random_bytes()
  2. Store only its SHA-256 hash in the database (just like passwords, we never store the raw value)
  3. Put user_id:raw_token in the cookie
  4. On verification, hash the token from the cookie and compare with the DB hash
  5. Rotate the token on every use - so even if an old cookie is stolen, it can't be reused
set_remember_cookie() & login_via_cookie()
function set_remember_cookie(int $userId): void
{
    $token       = bin2hex(random_bytes(32)); // 64-char raw token
    $tokenHash   = hash('sha256', $token);   // Store only the hash
    $cookieValue = $userId . ':' . $token;
    $expires     = date('Y-m-d H:i:s', time() + (COOKIE_DAYS * 86400));

    // Save hash to DB
    $pdo  = db_connect();
    $stmt = $pdo->prepare("UPDATE users SET remember_token=:t, token_expires=:e WHERE id=:id");
    $stmt->execute([':t' => $tokenHash, ':e' => $expires, ':id' => $userId]);

    // Set HttpOnly, SameSite cookie
    setcookie(COOKIE_NAME, $cookieValue, [
        'expires'  => time() + (COOKIE_DAYS * 86400),
        'path'     => '/',
        'secure'   => isset($_SERVER['HTTPS']),
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
}

function login_via_cookie(): bool
{
    if (empty($_COOKIE[COOKIE_NAME])) return false;
    [$userId, $token] = explode(':', $_COOKIE[COOKIE_NAME], 2);
    $tokenHash = hash('sha256', $token);

    $pdo  = db_connect();
    $stmt = $pdo->prepare(
        "SELECT * FROM users WHERE id=:id AND remember_token=:token
         AND token_expires > NOW() AND is_active=1"
    );
    $stmt->execute([':id' => (int)$userId, ':token' => $tokenHash]);
    $user = $stmt->fetch();

    if (!$user) { setcookie(COOKIE_NAME, '', time()-3600, '/'); return false; }

    login_user($user);
    set_remember_cookie($user['id']); // Rotate token
    return true;
}

5login.php - The Form & Handler

The login page has two jobs: show the form (with all security fields pre-wired), and process the POST. Here's the full flow of what happens when someone hits Submit:

1
CSRF check - csrf_validate() compares the submitted token with the session token. If they don't match, the request is rejected immediately.
2
reCAPTCHA check - verify_recaptcha() sends the JS-generated token to Google's API and checks the score. Score below 0.5 = rejected.
3
Basic validation - empty fields and invalid email format are caught before any DB query.
4
Lockout check - is_locked_out() queries the login_attempts table. If 5+ failures exist in the last 30 minutes, the form is blocked and a countdown is shown.
5
User lookup - a prepared statement fetches the user by email where is_active = 1. We deliberately don't tell the user whether the email or password was wrong (generic error message).
6
Password verify - password_verify() compares the submitted password against the bcrypt hash. On failure, record_failed_attempt() is called and the remaining attempts counter updates.
7
Success - clear_failed_attempts() wipes the attempt log, login_user() starts the session, CSRF token is regenerated, Remember Me cookie is set if checked, then redirect to dashboard.
login.php - POST handler (condensed)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    // 1. CSRF
    if (!csrf_validate()) {
        $error = 'Invalid form submission. Please refresh and try again.';
    }
    // 2. reCAPTCHA
    elseif (!verify_recaptcha($_POST['g-recaptcha-response'] ?? '')) {
        $error = 'reCAPTCHA verification failed. Please try again.';
    }
    else {
        $email    = trim($_POST['email'] ?? '');
        $password = $_POST['password'] ?? '';
        $remember = isset($_POST['remember']);

        // 3. Basic validation
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $error = 'Please enter a valid email address.';
        }
        // 4. Lockout check
        elseif (is_locked_out($email)) {
            $mins  = lockout_remaining_minutes($email);
            $error = "Account locked. Try again in $mins minute(s).";
        }
        else {
            // 5. User lookup
            $stmt = $pdo->prepare("SELECT * FROM users WHERE email=:e AND is_active=1");
            $stmt->execute([':e' => $email]);
            $user = $stmt->fetch();

            // 6. Password verify
            if (!$user || !password_verify($password, $user['password'])) {
                record_failed_attempt($email);
                $rem   = attempts_remaining($email);
                $error = "Invalid credentials. $rem attempt(s) remaining.";
            }
            // 7. Success
            else {
                clear_failed_attempts($email);
                login_user($user);
                csrf_regenerate();
                if ($remember) set_remember_cookie($user['id']);
                header('Location: dashboard.php'); exit;
            }
        }
    }
}
PHP PDO login error

6Wiring Up reCAPTCHA v3 in the Form

reCAPTCHA v3 works differently from v2 - there's no widget the user interacts with. The JS runs silently, and when the user clicks Submit, we ask Google for a score token right before the form posts:

reCAPTCHA v3 in the form
<!-- Load reCAPTCHA script in <head> -->
<script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY"></script>

<!-- Hidden field to carry the token to PHP -->
<input type="hidden" name="g-recaptcha-response" id="recaptchaToken">

<!-- In your form submit handler -->
<script>
document.getElementById('loginForm').addEventListener('submit', function(e) {
    e.preventDefault(); // Stop the form submitting immediately

    grecaptcha.ready(function() {
        grecaptcha.execute('YOUR_SITE_KEY', {action: 'login'}).then(function(token) {
            document.getElementById('recaptchaToken').value = token;
            document.getElementById('loginForm').submit(); // Now submit with token
        });
    });
});
</script>

7Protecting Any Page with require_login()

Every page in your app that needs authentication gets just two lines at the top. If the user isn't logged in, they're sent to the login page. If they have a valid Remember Me cookie, they're silently logged in and continue:

Protecting a page
<?php
require_once 'auth.php';
require_login('login.php'); // Redirect here if not logged in

// Your protected page code here
echo "Welcome, " . htmlspecialchars($_SESSION['user_name']);
?>
PHP PDO login

8Logout - Clean Session Destruction

Logging out properly means destroying the session, clearing the Remember Me cookie from both the browser and the database, and wiping the session array before calling session_destroy():

logout.php
<?php
require_once 'auth.php';
start_secure_session();
logout_user(); // Clears session + DB token + cookie
header('Location: login.php');
exit;

Production Security Checklist

Before going live, run through this list:

ItemWhy It MattersStatus
Replace RECAPTCHA_SITE_KEY & RECAPTCHA_SECRET_KEYDemo keys don't work on live domainsRequired
Update DB_USER / DB_PASSNever use root in productionRequired
Change the seeded admin passwordDefault password is publicRequired
Enable HTTPSsecure cookie flag only works over HTTPSCritical
Move config.php above the web rootPrevents direct browser access to credentialsCritical
Add error_reporting(0) and ini_set('display_errors', 0)Never expose PHP errors to visitorsRequired
Add a periodic cleanup job for old login_attempts rowsTable will grow forever without itRecommended
Set secure flag to true in session cookie paramsDetects HTTPS automatically in the codeDone

Best Practices & Common Mistakes

Never use MD5 or SHA1 for passwords. These are hashing algorithms, not password hashing algorithms. They're fast - which is exactly the problem. Bcrypt is deliberately slow, making brute-force attacks take orders of magnitude longer.

Always use generic error messages. Don't tell the user "email not found" or "wrong password" separately - this lets attackers enumerate valid email addresses. Use a single message: "Invalid email or password."

Lockout by email, not just by IP. Attackers rotate IPs. Locking by email ensures the target account is protected regardless of where the attack originates.

Rotate the Remember Me token on every use. If an attacker somehow steals a cookie, rotating the token on the very next legitimate login invalidates it immediately.

Log security events. In production, write failed login attempts and lockout events to a log file or monitoring tool. Silent failures are hard to debug and impossible to audit.

Summary

In this tutorial you built a complete, production-grade PHP login system covering:

  • PDO prepared statements - every DB query is injection-proof
  • CSRF tokens with timing-attack-safe hash_equals() comparison
  • Google reCAPTCHA v3 - invisible to real users, verified server-side
  • Brute-force lockout - 5 failures in 30 minutes triggers a countdown lock
  • Remember Me cookie - hashed token, HttpOnly, rotating on every use
  • Session hardening - fixation protection, inactivity timeout, IP consistency

Download the Full Source Code

All 8 files, database.sql, and setup instructions included. No account required.
email: admin@a2zwebhelp.com | Password: password