Navigation
 Startseite
 Fachbücher
 Anzeigenmarkt
 Forum
 Webmaster News
 Script Newsletter
 Kontakt
 Script Installation
 Php
 Php Tutorials
 Lernpfade
 Webhoster Vergleich
 Impressum

Community-Bereich
 kostenlos Registrieren
 Anmelden
 Benutzerliste

Script Datenbank
 Script Archiv
 Script Top 20
 Screenshots
 Testberichte

Suche
 

Unsere Php Scripts
 Counter Script
 Umfrage Script
 Bilder Upload Script
 Terminverwaltung
 Simple PHP Forum
 RSS Grabber

Tools und Generatoren
 .htpasswd Generator
 md5 Generator
 base64 Generator
 Markdown to HTML
 Colorpicker
 Unix timestamp Tool
 Unit Test Generator
 TLD Liste
 Webkatalog‑Verzeichnis

Artfiles.de
Bietet Serviceorientierte Internetdienstleistungen...
https://www.Artfiles.de
Hosterplus.de
Bekommen Sie Speicherplatz (Webspace), Domains und...
https://www.Hosterplus.de
 
 
 

PHP Login-System bauen: Sicheres Auth-System Schritt für Schritt

Sie befinden sich: Home > Php Tutorial > PHP Login-System bauen: Sic...

PHP Login-System bauen: Sicheres Auth-System Schritt für Schritt
Eintrag am:
18.07.2026
Hits / Besucher:
46
Sprache:
  Deutsch
Tutorial Art:
eigenes
Eingetragen von:
Merkliste:
 
Beschreibung

Kaum eine Web-Anwendung kommt ohne Anmeldung aus, und kaum ein Bereich wird so oft unterschätzt. Ein Login, das im Ernstfall standhält, besteht aus mehreren Bausteinen, die sauber ineinandergreifen müssen. Dieses Tutorial baut ein solches System von der Datenbank bis zum Logout Schritt für Schritt auf und zeigt jeden Baustein mit kopierfähigem Code.

Was gehört in ein PHP Login-System?

Ein vollständiges PHP Login System besteht aus mehr als einem Formular und einer SQL-Abfrage. Im typischen Stack aus PHP und MySQL braucht eine produktionsreife Auth-Logik ein durchdachtes Datenbank-Schema, eine sichere Registrierung mit gehashten Passwörtern, einen Login-Flow mit Eingabe von E-Mail und Passwort sowie Session-Rotation, einen CSRF-Schutz, einen Bruteforce-Bremsmechanismus, einen optionalen Remember-Me-Cookie und einen sauberen Logout. Klingt viel, ist aber strukturierbar.

Illustration zum Tutorial: PHP Login-System bauen: Sicheres Auth-System Schritt für Schritt

Bevor das Datenbank-Schema und die einzelnen Code-Bausteine folgen, ein kurzer Überblick über die Architektur und die genutzten PHP-Funktionen.

Dieses Tutorial führt Schritt für Schritt durch die Komponenten und zeigt jede mit kopierfähigem Code. Am Ende steht ein PHP Login System, das in einem realen Projekt eingesetzt werden kann und nicht beim ersten Penetrationstest auseinanderfällt. Wer einzelne Bausteine vertiefen möchte, findet in den verlinkten Tutorials zu password_hash, PDO und session_start() ausführliche Hintergründe.

Datenbank-Schema für users

Das Fundament jedes PHP Login Systems ist eine users-Tabelle mit klar definierten Spalten. Die folgende Struktur deckt die Standard-Anforderungen ab und lässt Platz für kommende Features wie 2FA oder Account-Sperrung.

<?php

/* SQL-Schema:
*
* CREATE TABLE users (
* id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
* email VARCHAR(190) NOT NULL UNIQUE,
* password_hash VARCHAR(255) NOT NULL,
* created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
* last_login DATETIME NULL,
* login_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
* locked_until DATETIME NULL
* ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
*
* CREATE TABLE remember_tokens (
* selector CHAR(24) NOT NULL PRIMARY KEY,
* token_hash CHAR(64) NOT NULL,
* user_id INT UNSIGNED NOT NULL,
* expires_at DATETIME NOT NULL,
* FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
* ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
*/

Die zweite Tabelle remember_tokens brauchen wir später für den optionalen Remember-Me-Cookie. Die Spalte email ist mit 190 Zeichen begrenzt, damit ein UNIQUE-Index unter utf8mb4 funktioniert. login_attempts und locked_until sind die Bausteine für die Bruteforce-Bremse.

Registrierungs-Formular mit Validierung

Bevor das PHP-Skript die Registrierung verarbeitet, braucht es ein Formular, das die Eingaben einsammelt. Das HTML bleibt einfach, wichtig sind die HTML5-Validierungs-Attribute und das CSRF-Token aus der Session. Fehler werden pro Feld zurückgespielt.

<?php

session_start();
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
$fehler = $_SESSION['fehler'] ?? [];
unset($_SESSION['fehler']);
?>
<form method="post" action="/register.php" novalidate>
<input type="hidden" name="csrf"
value="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8') ?>">

<label>
E-Mail
<input type="email" name="email" required maxlength="190"
value="<?= htmlspecialchars($_POST['email'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
</label>
<?php if (isset($fehler['email'])): ?>
<p class="is-invalid"><?= htmlspecialchars($fehler['email']) ?></p>
<?php endif; ?>

<label>
Passwort (mindestens 10 Zeichen)
<input type="password" name="password" required minlength="10">
</label>
<?php if (isset($fehler['password'])): ?>
<p class="is-invalid"><?= htmlspecialchars($fehler['password']) ?></p>
<?php endif; ?>

<label>
Passwort wiederholen
<input type="password" name="password2" required minlength="10">
</label>

<button type="submit">Konto anlegen</button>
</form>

Die Server-seitige Validierung prüft anschliessend, dass die E-Mail formal gültig ist, beide Passworte übereinstimmen und die Mindestlänge erreicht ist. Der Trick mit dem Fehler-Array in der Session: bei einem Validierungsfehler landen die Meldungen pro Feld dort, das Formular zeigt sie an und löscht sie nach dem nächsten Aufruf. So bleibt der Code im Formular übersichtlich, ohne komplette Form-Builder-Bibliothek.

Registrierung mit password_hash

Die Registrierung ist der erste Touchpoint im PHP Login System. Sie validiert die Eingaben, hashed das Passwort mit password_hash() (Default-Algorithmus) und speichert den Hash via Prepared Statement.

<?php

$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$pass = (string) ($_POST['password'] ?? '');

if (!$email || strlen($pass) < 10) {
exit('Bitte E-Mail und Passwort (min. 10 Zeichen) angeben.');
}

$hash = password_hash($pass, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
try {
$stmt->execute([$email, $hash]);
} catch (PDOException $e) {
/* Duplicate Email = neutral antworten, sonst Account-Enumeration */
exit('Registrierung nicht moeglich.');
}
echo 'Account erstellt.';

Wichtig: bei einem UNIQUE-Conflict NICHT zurückmelden, dass die E-Mail bereits existiert. Sonst können Angreifer per Trial-and-Error herausfinden, welche Konten registriert sind. Das nennt man Account-Enumeration und ist eine der häufigsten Schwachstellen in einem unüberlegten PHP Login System.

Login mit password_verify und Session-Rotation

Der Login ist das Kernstück im PHP Login System. Er prüft das Passwort mit password_verify(), rotiert die Session-ID mit session_regenerate_id() und merkt sich Fehlversuche für die Bruteforce-Bremse.

<?php

session_start();
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
exit('Ungueltiges Token.');
}

$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$stmt = $pdo->prepare('SELECT id, password_hash, locked_until, login_attempts FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

if ($user && $user['locked_until'] !== null && strtotime($user['locked_until']) > time()) {
exit('Konto temporaer gesperrt.');
}

if ($user && password_verify($_POST['password'], $user['password_hash'])) {
$pdo->prepare('UPDATE users SET last_login = NOW(), login_attempts = 0, locked_until = NULL WHERE id = ?')
->execute([$user['id']]);
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
header('Location: /dashboard.php');
exit;
}

if ($user) {
$attempts = (int) $user['login_attempts'] + 1;
$sql = $attempts >= 5
? 'UPDATE users SET login_attempts = ?, locked_until = DATE_ADD(NOW(), INTERVAL 15 MINUTE) WHERE id = ?'
: 'UPDATE users SET login_attempts = ? WHERE id = ?';
$pdo->prepare($sql)->execute([$attempts, $user['id']]);
}
exit('Login fehlgeschlagen.');

Der Aufruf session_regenerate_id(true) ist Pflicht. Ohne ihn wäre das Login-System anfällig für Session-Fixation. Mehr Hintergrund liefert das Tutorial zu session_regenerate_id.

CSRF-Schutz im Formular

Das Login-Formular braucht ein CSRF-Token, damit ein Angreifer nicht aus einem fremden Tab POST-Requests im Namen des Nutzers absetzen kann. Das Token wird beim Aufruf der Login-Seite generiert und beim POST verglichen.

<?php

session_start();
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
?>
<form method="post" action="/login.php">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8') ?>">
<input type="email" name="email" required>
<input type="password" name="password" required>
<button type="submit">Login</button>
</form>

Der Vergleich im Login-Skript erfolgt mit hash_equals(), nicht mit ===. Das schützt vor sogenannten Timing-Angriffen, bei denen ein Angreifer über gemessene Antwortzeiten auf den Token-Inhalt schließen könnte.

Bruteforce bremsen

Die Bruteforce-Bremse verhindert, dass jemand Tausende Passwörter pro Sekunde durchprobiert. Im obigen Login-Code haben wir bereits login_attempts und locked_until integriert. Nach fünf Fehlversuchen wird das Konto für 15 Minuten gesperrt.

<?php

/* Erweiterung: zusaetzlich pro IP zaehlen */
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$stmt = $pdo->prepare('
INSERT INTO login_attempts_per_ip (ip, attempt_at)
VALUES (?, NOW())
');
$stmt->execute([$ip]);

Hinweis: Die Tabelle login_attempts_per_ip ist im obigen Schema bewusst nicht enthalten und muss separat angelegt werden, etwa als CREATE TABLE login_attempts_per_ip (ip VARCHAR(45) NOT NULL, attempt_at DATETIME NOT NULL, INDEX (ip, attempt_at)) ENGINE=InnoDB. Wer noch tiefer absichern will, kombiniert Account- und IP-basierte Limitierung. Das verhindert auch, dass Angreifer mehrere Konten gleichzeitig durchprobieren.

Remember-Me mit Selector und Token

Ein Remember-Me-Cookie macht das wiederholte Einloggen überflüssig. Das sichere Pattern arbeitet mit zwei Werten: einem öffentlichen Selector und einem geheimen Token. Der Selector dient als Index in der DB, der Token wird gehashed gespeichert.

<?php

$selector = bin2hex(random_bytes(12));
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);

$stmt = $pdo->prepare('
INSERT INTO remember_tokens (selector, token_hash, user_id, expires_at)
VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))
');
$stmt->execute([$selector, $tokenHash, $userId]);

setcookie('remember', $selector . ':' . $token, [
'expires' => time() + 60 * 60 * 24 * 30,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);

Bei einem nachfolgenden Request liest die Anwendung den Cookie, splittet Selector und Token und vergleicht den gehashten Token mit dem Eintrag in der DB.

<?php

if (isset($_COOKIE['remember']) && str_contains($_COOKIE['remember'], ':')) {
[$selector, $token] = explode(':', $_COOKIE['remember'], 2);
$stmt = $pdo->prepare('SELECT user_id, token_hash, expires_at FROM remember_tokens WHERE selector = ?');
$stmt->execute([$selector]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

if ($row && strtotime($row['expires_at']) > time()
&& hash_equals($row['token_hash'], hash('sha256', $token))
) {
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $row['user_id'];
}
}

Geschuetzte Seite: das Guard-Pattern

Ein PHP Login System ohne geschützte Bereiche wäre nutzlos. Damit nur eingeloggte Nutzer auf das Dashboard kommen, steht am Anfang jeder geschützten Seite ein einfacher Guard. Der Code lässt sich in eine zentrale Helper-Datei auslagern und per require einbinden.

<?php

/* helpers/auth.php - in jedem geschuetzten Skript per require einbinden */
function requireLogin(): int
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
if (empty($_SESSION['user_id'])) {
$_SESSION['after_login'] = $_SERVER['REQUEST_URI'] ?? '/';
header('Location: /login.php');
exit;
}
return (int) $_SESSION['user_id'];
}

/* dashboard.php */
require __DIR__ . '/helpers/auth.php';
$userId = requireLogin();
echo 'Hallo User ' . htmlspecialchars((string) $userId);

Der zusätzliche Trick mit $_SESSION['after_login'] macht den Login komfortabel: Wer eine geschützte Seite ohne Login aufruft, landet auf dem Login-Formular und wird nach erfolgreichem Login direkt auf die ursprünglich angeforderte Seite weitergeleitet. Im Login-Skript reicht ein header('Location: ' . ($_SESSION['after_login'] ?? '/')) nach dem erfolgreichen password_verify(). Dieses Pattern entspricht der Middleware oder dem Guard aus Frameworks wie Symfony und Laravel, nur ohne deren Routing-Komplexität.

User-Roles und Authorization

Sobald die Anwendung mehr als einen Nutzertyp kennt, kommen User-Roles ins Spiel. Authentifizierung beantwortet die Frage "Wer ist das?", Autorisierung beantwortet "Was darf der?". Die einfachste Lösung ist eine zusätzliche Spalte in der users-Tabelle.

<?php

/* SQL-Erweiterung:
* ALTER TABLE users
* ADD COLUMN role ENUM('user','editor','admin')
* NOT NULL DEFAULT 'user';
*/

function requireRole(string $needed): int
{
$userId = requireLogin();
if (empty($_SESSION['role'])) {
/* Rolle in der Session cachen, einmal pro Login */
global $pdo;
$stmt = $pdo->prepare('SELECT role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$_SESSION['role'] = (string) ($stmt->fetchColumn() ?: 'user');
}
$rang = ['user' => 1, 'editor' => 2, 'admin' => 3];
if (($rang[$_SESSION['role']] ?? 0) < ($rang[$needed] ?? 0)) {
http_response_code(403);
exit('Zugriff verweigert.');
}
return $userId;
}

/* admin/users.php */
require __DIR__ . '/../helpers/auth.php';
requireRole('admin');
echo 'Admin-Bereich';

Die Rolle wird beim Login einmalig in der Session gecached, damit nicht jede Page-Load-Anfrage einen DB-Roundtrip kostet. Bei einer Rollen-Änderung muss die Session neu gestartet oder der Cache-Eintrag invalidiert werden, sonst läuft der Nutzer mit veralteter Rolle weiter. Für komplexere Berechtigungs-Modelle (mehrere Rollen pro Nutzer, granulare Permissions) lohnt eine eigene roles- und user_roles-Tabelle mit Many-to-many-Beziehung. Das hier gezeigte Pattern reicht aber für 90 Prozent aller Web-Apps.

Passwort-Reset-Flow mit Token und E-Mail

Vergessene Passworte sind Alltag. Ein sauberer Passwort-Reset-Flow funktioniert immer nach dem gleichen Prinzip: ein einmaliger Token landet per E-Mail beim Nutzer, der Link führt auf eine Seite mit neuem Passwort-Formular, der Token wird nach Verwendung invalidiert. Die DB braucht eine eigene Tabelle dafür.

<?php

/*
* CREATE TABLE password_resets (
* selector CHAR(24) NOT NULL PRIMARY KEY,
* token_hash CHAR(64) NOT NULL,
* user_id INT UNSIGNED NOT NULL,
* expires_at DATETIME NOT NULL,
* used_at DATETIME NULL,
* FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
* INDEX (user_id)
* ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
*/

/* Schritt 1: Reset anfordern (forgot.php) */
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
if ($email) {
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

if ($user) {
$selector = bin2hex(random_bytes(12));
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);

$pdo->prepare('INSERT INTO password_resets
(selector, token_hash, user_id, expires_at)
VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 60 MINUTE))')
->execute([$selector, $tokenHash, $user['id']]);

$link = 'https://example.com/reset.php?s=' . $selector . '&t=' . $token;
mail($email, 'Passwort zuruecksetzen',
"Klicke binnen 60 Minuten auf diesen Link:\n" . $link);
}
}
/* Immer dieselbe Antwort, egal ob die E-Mail existiert */
echo 'Falls ein Konto existiert, wurde eine E-Mail verschickt.';

Der zweite Schritt ist die eigentliche Passwort-Setzung. Das Skript prüft Selector und Token-Hash, akzeptiert das neue Passwort und markiert den Token als verwendet, damit derselbe Link nicht doppelt funktioniert. Beide Schreibvorgänge laufen dabei in einer PDO-Transaktion, damit kein halb fertiger Zustand zurückbleibt.

<?php

/* Schritt 2: Passwort neu setzen (reset.php) */
$selector = $_GET['s'] ?? $_POST['s'] ?? '';
$token = $_GET['t'] ?? $_POST['t'] ?? '';

$stmt = $pdo->prepare('SELECT user_id, token_hash, expires_at, used_at
FROM password_resets WHERE selector = ?');
$stmt->execute([$selector]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$row
|| $row['used_at'] !== null
|| strtotime($row['expires_at']) < time()
|| !hash_equals($row['token_hash'], hash('sha256', $token))
) {
exit('Reset-Link ungueltig oder abgelaufen.');
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$neu = (string) ($_POST['password'] ?? '');
if (strlen($neu) < 10) { exit('Mindestens 10 Zeichen.'); }

$hash = password_hash($neu, PASSWORD_DEFAULT);
$pdo->beginTransaction();
$pdo->prepare('UPDATE users SET password_hash = ?, login_attempts = 0,
locked_until = NULL WHERE id = ?')
->execute([$hash, $row['user_id']]);
$pdo->prepare('UPDATE password_resets SET used_at = NOW() WHERE selector = ?')
->execute([$selector]);
/* Sicherheitsmassnahme: alle Remember-Me-Tokens des Users invalidieren */
$pdo->prepare('DELETE FROM remember_tokens WHERE user_id = ?')
->execute([$row['user_id']]);
$pdo->commit();

echo 'Passwort gesetzt. Bitte neu einloggen.';
}

Drei Sicherheitsdetails machen den Reset-Flow robust. Erstens das Selector-plus-Token-Pattern wie beim Remember-Me-Cookie. Selector als öffentlicher Index, Token nur als gehashter Wert in der DB. Zweitens die kurze Ablaufzeit von 60 Minuten, sonst sammeln sich offene Reset-Links unbegrenzt an. Drittens das Invalidieren aller Remember-Me-Tokens nach dem Passwort-Wechsel: wenn das Passwort gestohlen wurde, fliegen damit auch alle alten Sessions raus.

Logout sauber implementieren

Logout ist mehr als session_destroy(). Die Session-Variablen müssen geleert, der Session-Cookie ausgelöscht und ein eventueller Remember-Me-Eintrag in der DB entfernt werden.

<?php

session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 3600, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
}
session_destroy();

if (isset($_COOKIE['remember'])) {
[$selector, ] = explode(':', $_COOKIE['remember'], 2);
$pdo->prepare('DELETE FROM remember_tokens WHERE selector = ?')->execute([$selector]);
setcookie('remember', '', time() - 3600, '/');
}
header('Location: /');
exit;

Damit ist der Loop geschlossen: Registrieren, Einloggen, optional gemerkter Login, Logout und Cleanup. Wer das System auf 2FA erweitern möchte, fügt nach erfolgreichem Login einen zweiten Schritt ein, der erst dann $_SESSION['user_id'] setzt, wenn der zweite Faktor bestätigt ist.

Wie die einzelnen Bausteine vom ersten Konto bis zum Logout zusammenspielen, zeigt der folgende Ablauf im Überblick.

flowchart TD
    A[Registrierung] --> B[users-Tabelle mit password_hash]
    B --> C[Login-Formular mit CSRF-Token]
    C --> D[password_verify und session_regenerate_id]
    D --> E[Session mit user_id]
    E --> F{Remember-Me?}
    F -->|Ja| G[Selector + Token Cookie + DB]
    F -->|Nein| H[Nur Session]
    E --> I[Logout: Session zerstoeren + Cookie loeschen]

Fazit

Ein gutes PHP Login System entsteht aus dem Zusammenspiel weniger, klar getrennter Bausteine: ein durchdachtes Schema, password_hash und password_verify für das Passwort, Session-Rotation gegen Fixation, ein CSRF-Token gegen Cross-Site-Requests, eine Bruteforce-Bremse gegen Maßen-Versuche, ein optionaler Remember-Me-Cookie nach Selector-Token-Pattern und ein vollständiger Logout. Wer diese Komponenten kombiniert, hat ein PHP Login System, das im Alltag einsatzbereit ist und sich später ohne Reißaktion auf 2FA, SSO oder OAuth erweitern lässt. Die hier gezeigten Snippets sind so geschrieben, dass sie einsatzfähig in ein neues Projekt übernommen werden können.

 

Tags:

 

 

Kommentare (0)

Noch keine Kommentare. Sei der Erste!

Melde dich an, um einen Kommentar zu schreiben.
Bücherregal mit drei Büchern: 'PHP 4 - Grundlagen und Profiwissen' von Hanser Verlag, 'Webdesign in a Nutshell' von O'Reilly Verlag, und 'Webgestaltung' von Galileo Computing.