Skip to content
Let’s plan the right software for your processes. Call us for a demo or a quote: +90 546 737 48 29

TR EN DE

Login and session security

The login screen is the door to your site; the session is the key that carries the identity of the person who came in on every subsequent request. If either of the two is weak, accounts can be taken over no matter how solid the rest of the code is. The common problems are well known: passwords stored with a weak method, unlimited password attempts, a session ID that is not regenerated at login, unprotected cookies, and permissions that are only hidden in the menu but not checked on the server.

This guide explains how to build the login and session layer correctly on a site written in PHP. For measures on the user side, see the Strong passwords and password managers guide.

In brief

  • Passwords are stored with password_hash; MD5 and SHA-1 are not suitable.
  • Login attempts are limited per account and per IP; the error message is generic.
  • The session ID is regenerated at login; the cookie is issued with the Secure, HttpOnly and SameSite flags.
  • Authorisation is checked on the server for every request; hiding the menu is not enough.
On this page

Four fundamentals

  1. Store the hash, not the password

    Passwords are never stored in the database as plain text or in a reversible form. Fast hash functions such as MD5, SHA-1 and unsalted SHA-256 are not suitable for storing passwords either; because they are fast, the passwords in a leaked database can be tried in a short time. PHP's password_hash function uses a slow algorithm designed for passwords and generates the salt automatically.

    PHP
    <?php
    // Registration or password change
    $karma = password_hash($parola, PASSWORD_DEFAULT);
    // $karma is written to the database (column: VARCHAR(255))
    
    // Login
    if (password_verify($girilenParola, $kullanici['parola_karma'])) {
        // Update the hash if the algorithm or cost has changed
        if (password_needs_rehash($kullanici['parola_karma'], PASSWORD_DEFAULT)) {
            $yeni = password_hash($girilenParola, PASSWORD_DEFAULT);
            $pdo->prepare('UPDATE kullanicilar SET parola_karma = ? WHERE id = ?')
                ->execute([$yeni, $kullanici['id']]);
        }
    }
    Comparison: storing passwords as plain text, MD5 or SHA-1 is wrong; password_hash and password_verify are right : Enlarge
  2. Limit the number of attempts

    An unlimited number of attempts makes password guessing possible. Count failed attempts both per account and per IP address; after a certain number, apply a waiting period and increase it step by step. The error message should not reveal whether it was the username or the password that was wrong.

    Flow diagram: login attempt, attempt count check, password verification, session ID regeneration, authorisation : Enlarge
  3. Regenerate the session ID at login

    Session fixation arises when the session ID the user had before logging in remains valid after login; someone who knows the ID in advance can use the same session once the user has logged in. The solution is simple: regenerate the session ID when login succeeds and whenever the privilege level changes.

    Diagram: session cookie flags; Secure, HttpOnly, SameSite and a session ID regenerated at login : Enlarge
  4. Check authorisation on the server for every request

    Hiding a button or a menu item is not an authorisation check; anyone who knows the address can send the request directly. Every page and every action must check on the server first that the session is valid, and then that the user is authorised to perform that action on that record.

    Comparison: only hiding the menu is wrong; checking session, role and record ownership on the server for every request is right : Enlarge

Signs: how do you spot it?

  • In the database: If you see 32- or 40-character hexadecimal values (MD5, SHA-1) or readable passwords in the password column, the storage method is weak. The output of password_hash starts with $2y$ or $argon2.
  • In the logs: A large number of POST requests to the login address in a short time; repeated attempts with different usernames; successful logins from unusual countries or at unusual hours.
  • In the browser: If the session cookie lacks the Secure, HttpOnly and SameSite flags in the developer tools; if the session ID is the same before and after login.
  • In the application: If, after logging out, the content appears when the address of an admin page is typed in directly; if changing the record number in the address opens another user's record.

A secure login flow

The example below shows the attempt limit, verification of the password, updating the hash where necessary and regeneration of the session ID, all together.

PHP
<?php
$eposta = strtolower(trim((string) ($_POST['eposta'] ?? '')));
$parola = (string) ($_POST['parola'] ?? '');
$ip     = $_SERVER['REMOTE_ADDR'] ?? '';

if (cokFazlaDeneme($pdo, $eposta, $ip)) {
    http_response_code(429);
    echo 'Too many attempts. Please try again in 15 minutes.';
    return;
}

$sorgu = $pdo->prepare('SELECT id, parola_karma, rol FROM kullanicilar WHERE eposta = ? AND aktif = 1');
$sorgu->execute([$eposta]);
$kullanici = $sorgu->fetch();

// Verify even if there is no such user: the response time must not give away whether the user exists.
// SAHTE_KARMA: a dummy hash generated once with password_hash() and stored as a constant in the configuration
$karma = $kullanici ? $kullanici['parola_karma'] : SAHTE_KARMA;
$dogru = password_verify($parola, $karma) && $kullanici !== false;

if (!$dogru) {
    denemeKaydet($pdo, $eposta, $ip);
    echo 'Incorrect email or password.';
    return;
}

session_regenerate_id(true);               // against session fixation
$_SESSION['kullanici_id'] = (int) $kullanici['id'];
$_SESSION['rol']          = $kullanici['rol'];
$_SESSION['son_islem']    = time();
$_SESSION['csrf']         = bin2hex(random_bytes(32));
  • A hash verification is performed even when the user is not found; this way the response time does not reveal whether the user exists.
  • There is a single, generic error message: "Incorrect email or password."
  • Prefer a timed wait to a permanent account lockout; otherwise someone else can lock users out of their accounts with deliberately wrong attempts.
  • The login form should carry a CSRF token.

A simple table is enough for the attempt counter:

PHP
<?php
// Table: giris_denemeleri (eposta VARCHAR(190), ip VARCHAR(45), zaman DATETIME, INDEX(eposta), INDEX(ip))
function cokFazlaDeneme(PDO $pdo, string $eposta, string $ip): bool
{
    $sorgu = $pdo->prepare(
        'SELECT COUNT(*) FROM giris_denemeleri
         WHERE (eposta = ? OR ip = ?) AND zaman > (NOW() - INTERVAL 15 MINUTE)'
    );
    $sorgu->execute([$eposta, $ip]);
    return (int) $sorgu->fetchColumn() >= 5;
}

function denemeKaydet(PDO $pdo, string $eposta, string $ip): void
{
    $pdo->prepare('INSERT INTO giris_denemeleri (eposta, ip, zaman) VALUES (?, ?, NOW())')
        ->execute([$eposta, $ip]);
}

Migrating from an old hashing method

If passwords on an existing site are stored with MD5 or SHA-1, you can migrate without making users reset their passwords: when a user logs in, the password is verified with the old method and, if it is correct, re-hashed with password_hash and saved at the same moment. Because the risk remains for as long as the old hashes stay in the database, a password reset should be made compulsory after a set period for accounts that have still not migrated.

Session settings

Set the session cookie flags and the session behaviour before calling session_start.

PHP
<?php
ini_set('session.use_strict_mode', '1');   // reject IDs not generated by the server
ini_set('session.use_only_cookies', '1');  // the ID is never carried in the address

session_set_cookie_params([
    'lifetime' => 0,          // ends when the browser is closed
    'path'     => '/',
    'secure'   => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);
session_start();

// Inactivity timeout: 30 minutes
$sure = 30 * 60;
if (isset($_SESSION['son_islem']) && time() - $_SESSION['son_islem'] > $sure) {
    $_SESSION = [];
    session_destroy();
    header('Location: giris?oturum=doldu');
    return;
}
$_SESSION['son_islem'] = time();
  • Secure: The cookie is sent only over HTTPS.
  • HttpOnly: JavaScript cannot read the cookie; this makes it harder to steal the cookie in the event of XSS.
  • SameSite: Restricts the cookie from being sent with requests initiated from other sites.
  • Strict mode: Rejects session IDs that were not generated by the server.
  • Timeout: End a session that has been inactive for a set time; keep the period short in admin panels.
  • Never put the session ID in the address (URL).

At logout, really destroy the session:

PHP
<?php
session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
    $p = session_get_cookie_params();
    setcookie(session_name(), '', [
        'expires'  => time() - 3600,
        'path'     => $p['path'],
        'domain'   => $p['domain'],
        'secure'   => $p['secure'],
        'httponly' => $p['httponly'],
        'samesite' => $p['samesite'] ?: 'Lax',
    ]);
}
session_destroy();
header('Location: giris');

Authorisation checks on every request

Gather the authorisation check into a shared function that is called at the top of every page. There are two separate questions: can the user perform this kind of action (role), and does this record belong to them (ownership)?

PHP
<?php
// Called at the top of every admin page
function yetkiIste(string $rol): void
{
    if (empty($_SESSION['kullanici_id'])) {
        header('Location: giris');
        exit;
    }
    if (($_SESSION['rol'] ?? '') !== $rol) {
        http_response_code(403);
        echo 'You are not authorised to view this page.';
        exit;
    }
}

// Record ownership: the user ID from the session is added to the query as well
$sorgu = $pdo->prepare('SELECT * FROM siparisler WHERE id = ? AND kullanici_id = ?');
$sorgu->execute([(int) ($_GET['id'] ?? 0), $_SESSION['kullanici_id']]);
$siparis = $sorgu->fetch();
if (!$siparis) {
    http_response_code(404);
    echo 'Order not found.';
    return;
}

Do not trust the record number in the address or in the form; always add the user ID from the session to the query as well. Access control failures are among the risks at the very top of the OWASP Top 10 list.

Protect the admin panel with extra layers

  • Two-factor authentication: Make it compulsory for administrator accounts. For the user side, see the Two-factor authentication guide.
  • IP restriction: If the panel is only accessed from your office's static IP address or over a VPN, restrict the panel folder by IP.
  • An extra password layer: If you have no static IP, a second password at server level (HTTP basic authentication) can be added to the panel folder.
  • Panel address: An address that is not easy to guess reduces the noise of automated scans; however, it is not protection on its own.
  • Personal accounts: Do not use a shared "admin" account; each administrator should have their own account, and the actions performed should be logged.
.htaccess (Apache)
# admin/.htaccess : access to the panel only from specific IP addresses
# (203.0.113.10 is an example address; enter your own static IP address)
Require ip 203.0.113.10

# If you have no static IP: a second password layer at server level
# AuthType Basic
# AuthName "Admin"
# AuthUserFile /home/user/.htpasswd
# Require valid-user

Password reset

  • The token in the reset link should be random (random_bytes), single-use and short-lived; its hash should be stored in the database.
  • Do not say "This email is not registered"; show the same message in every case: "If the address is registered, a reset link has been sent."
  • When the password changes, end the user's other open sessions and send a notification email.
  • Do not send the new password by email; let the user set it themselves.
  • In the password rules, put the emphasis on length; require at least 12 characters and reject very common passwords.

Checklist

  • Passwords are stored with password_hash; there is a migration plan for old hashes.
  • Login attempts are limited per account and per IP; the error message is generic.
  • session_regenerate_id(true) is called when login succeeds.
  • The session cookie has the Secure, HttpOnly and SameSite flags; strict mode is on.
  • There is an inactivity timeout; logout destroys the session.
  • Every page and action checks session, role and record ownership on the server.
  • Two-factor authentication on administrator accounts; an IP restriction or an extra password layer on the panel.
  • The password reset token is single-use and time-limited.
  • Successful and failed logins are written to the log.

Frequently asked questions

Which algorithm does PASSWORD_DEFAULT use?

It selects the recommended algorithm for the PHP version (bcrypt in current versions). If the algorithm changes in future, password_needs_rehash updates the hash automatically the next time the user logs in. That is why you should define the column as VARCHAR(255).

Should I add my own salt to the password?

No. password_hash generates a random salt for each password itself and stores it inside the hash. There is no need to add a salt by hand.

Can a captcha replace the attempt limit?

Not on its own. A captcha makes automated attempts harder; limiting the number of attempts on the server side is still necessary. The two can be used together.

Is the "Remember me" feature secure?

Yes, if done correctly: the long-lived cookie should contain not the user ID or the password but a random token whose hash is kept in the database; the token should be renewed on every use and revoked when the password changes.

BYK Yazılım Support Team
This guide is written and regularly reviewed by the BYK Yazılım support team. Last updated: 4 October 2026.

Related guides

Let us review your website together

BYK Yazılım builds corporate websites. Write to us with any questions about your site.

Contact us Our corporate website service