Berechtigungssysteme mit PHP: Rollen, Rechte & Multi-Level-Zugriff
Technologie & Architektur

Berechtigungssysteme mit PHP: Rollen, Rechte & Multi-Level-Zugriff

Carola Schulte
2. März 2026
14 min

Berechtigungssysteme mit PHP: Rollen, Rechte & Multi-Level-Zugriff

“Wer darf was sehen?” - Diese Frage klingt einfach. In der Praxis ist sie einer der häufigsten Gründe für Nacharbeit in Web-Anwendungen. Nicht weil die Technik schwierig wäre, sondern weil die Anforderungen selten so simpel sind, wie sie am Anfang klingen.

Das typische Muster: Projekt startet mit zwei Rollen - Admin und Nutzer. Drei Monate später soll der Teamleiter Berichte sehen, aber keine Nutzer löschen. Der Praktikant soll nur lesen. Der externe Berater soll nur sein Projekt sehen. Und plötzlich reicht if ($user->isAdmin()) nicht mehr.


Für Entscheider: Warum Berechtigungen früh geplant werden müssen

Ein Berechtigungssystem nachträglich einbauen ist wie eine Brandschutztür nachträglich in ein fertiges Gebäude setzen - technisch möglich, aber teuer und nie so sauber wie von Anfang an mitgeplant.

ZeitpunktAufwandRisiko
Von Anfang an mitgeplantModerate Mehrarbeit im DesignGering
Nach 6 Monaten nachgerüstetUmbau an Datenbank, API und FrontendMittel - bestehende Features müssen angepasst werden
Nach SicherheitsvorfallNotfall-Patch unter ZeitdruckHoch - hastige Fixes schaffen neue Lücken

Was auf dem Spiel steht: Ein Mitarbeiter sieht Gehaltsdaten, die nicht für ihn bestimmt sind. Ein Kunde greift auf Daten eines anderen Kunden zu. Ein deaktivierter Account hat noch Zugriff auf interne Bereiche. Das sind keine hypothetischen Szenarien - sie passieren regelmäßig in Anwendungen, bei denen Berechtigungen als “machen wir später” geplant wurden.


Die drei Modelle im Überblick

1. Einfache Rollen (Role-Based Access Control / RBAC)

Jeder Nutzer hat eine oder mehrere Rollen. Jede Rolle hat definierte Rechte.

Admin       → alles
Teamleiter  → lesen, schreiben, Berichte
Mitarbeiter → lesen, schreiben
Praktikant  → nur lesen

Datenbankmodell:

CREATE TABLE roles (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) UNIQUE NOT NULL,
    description TEXT
);

CREATE TABLE permissions (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) UNIQUE NOT NULL,  -- z.B. 'orders.create', 'users.delete'
    description TEXT
);

CREATE TABLE role_permissions (
    role_id INT REFERENCES roles(id) ON DELETE CASCADE,
    permission_id INT REFERENCES permissions(id) ON DELETE CASCADE,
    PRIMARY KEY (role_id, permission_id)
);

CREATE TABLE user_roles (
    user_id INT REFERENCES users(id) ON DELETE CASCADE,
    role_id INT REFERENCES roles(id) ON DELETE CASCADE,
    PRIMARY KEY (user_id, role_id)
);

PHP-Implementierung:

class Authorization
{
    private array $userPermissions = [];

    public function __construct(private PDO $db) {}

    public function loadPermissions(int $userId): void
    {
        $stmt = $this->db->prepare("
            SELECT DISTINCT p.name
            FROM permissions p
            JOIN role_permissions rp ON rp.permission_id = p.id
            JOIN user_roles ur ON ur.role_id = rp.role_id
            WHERE ur.user_id = ?
        ");
        $stmt->execute([$userId]);
        $this->userPermissions = $stmt->fetchAll(PDO::FETCH_COLUMN);
    }

    public function can(string $permission): bool
    {
        return in_array($permission, $this->userPermissions, true);
    }

    public function canAny(array $permissions): bool
    {
        return !empty(array_intersect($permissions, $this->userPermissions));
    }
}
// Verwendung
$auth = new Authorization($db);
$auth->loadPermissions($currentUser->id);

if (!$auth->can('orders.delete')) {
    http_response_code(403);
    exit('Keine Berechtigung');
}

Wann RBAC reicht: Die meisten Business-Anwendungen. Wenn sich die Berechtigungslogik sauber auf Rollen abbilden lässt und nicht vom konkreten Datensatz abhängt (also nicht “darf nur seine eigenen Aufträge sehen”).

2. Attribut-basiert (ABAC): Wenn Kontext entscheidet

RBAC fragt: “Hat der Nutzer die Rolle?” ABAC fragt: “Passen die Attribute zusammen?” - Nutzer-Attribute, Ressourcen-Attribute, Kontext.

// ABAC: Entscheidung hängt von Attributen ab
class PolicyEngine
{
    public function evaluate(User $user, string $action, object $resource): bool
    {
        // Regel 1: Eigene Aufträge darf jeder bearbeiten
        if ($resource instanceof Order && $resource->createdBy === $user->id) {
            return true;
        }

        // Regel 2: Teamleiter sehen Aufträge ihres Teams
        if ($action === 'view' && $resource instanceof Order) {
            return $this->isInSameTeam($user, $resource->createdBy);
        }

        // Regel 3: Freigabe nur bis bestimmtem Betrag
        if ($action === 'approve' && $resource instanceof Order) {
            $limit = $user->role === 'manager' ? 10000 : 5000;
            return $resource->amount <= $limit;
        }

        // Regel 4: Nur Entwürfe dürfen bearbeitet werden
        if ($action === 'edit' && $resource instanceof Order) {
            return $resource->status === 'draft';
        }

        return false; // Default: kein Zugriff
    }
}

Wann ABAC nötig ist: Wenn Berechtigungen vom konkreten Datensatz abhängen (“nur eigene Kunden”, “nur eigene Kostenstelle”), vom Status (“nur Entwürfe bearbeiten”), von Beträgen (“Freigabe bis 10.000 €”) oder von Kombinationen dieser Attribute.

Wann ABAC Overkill ist: Wenn einfache Rollen ausreichen. ABAC ist mächtig, aber auch deutlich komplexer zu debuggen und zu testen. Production-ABAC braucht strukturierte Regeln statt einer wachsenden if-Kette - z.B. JSON-basierte Policies, Open Policy Agent (OPA) oder eine eigene Rule-Engine. Im Zweifelsfall mit RBAC starten und bei Bedarf erweitern.

3. Multi-Tenancy: Mandantentrennung

Mehrere Kunden (Mandanten) nutzen dieselbe Anwendung, dürfen aber nur ihre eigenen Daten sehen. Das ist kein Berechtigungsmodell im engeren Sinne, sondern eine Architekturentscheidung - aber in der Praxis eng mit Berechtigungen verknüpft.

Das Prinzip: Jede Datenbankabfrage wird automatisch auf den aktiven Mandanten eingeschränkt. In der Praxis wird das über einen Query-Builder (z.B. Doctrine DBAL Global Filter, Laravel Eloquent Global Scope) oder Datenbank-Views mit fest eingebautem tenant_id-Filter erzwungen - nicht per String-Manipulation beliebiger SQL-Queries.

// Beispiel: Repository mit Tenant-Scope
class OrderRepository
{
    public function __construct(private PDO $db, private int $tenantId) {}

    public function findAll(): array
    {
        $stmt = $this->db->prepare(
            "SELECT * FROM orders WHERE tenant_id = ? ORDER BY created_at DESC"
        );
        $stmt->execute([$this->tenantId]);
        return $stmt->fetchAll();
    }
}

Achtung: Shared Schema mit tenant_id ist einfach, aber fehleranfällig. Ein einziger SELECT * FROM orders ohne WHERE tenant_id = ? reicht – und ein Mandant sieht die Daten aller anderen. Bei sensiblen Daten: PostgreSQL RLS als zusätzliche Absicherung oder separate Schemas, damit ein vergessener Filter nicht zum Datenleck wird.

Die drei Isolationsstufen:

StufeTrennungAufwandSicherheit
Shared Database, Shared Schematenant_id-Spalte in jeder TabelleGeringHängt von konsequenter Filterung ab
Shared Database, Separate SchemaEigenes DB-Schema pro MandantMittelGut - Schema-Grenzen schützen
Separate DatabaseEigene Datenbank pro MandantHochSehr gut - physische Trennung

Für die meisten SaaS-Anwendungen im KMU-Bereich reicht Shared Schema mit tenant_id. Bei sensiblen Daten (Gesundheit, Finanzen) oder regulatorischen Anforderungen kann eine stärkere Trennung nötig sein.


Berechtigungen in der Praxis: Patterns und Code

Permission-Naming-Konvention

Konsistente Benennung spart Debugging-Zeit:

// Konvention: resource.action
$permissions = [
    'orders.view',        // Aufträge ansehen
    'orders.create',      // Aufträge erstellen
    'orders.edit',        // Aufträge bearbeiten
    'orders.delete',      // Aufträge löschen
    'orders.export',      // Aufträge exportieren
    'reports.view',       // Berichte ansehen
    'reports.generate',   // Berichte generieren
    'users.manage',       // Nutzer verwalten
    'settings.edit',      // Einstellungen ändern
];

Warum resource.action statt Freitext: Permissions lassen sich automatisch aus Routen generieren, in der UI gruppiert anzeigen und per Wildcard prüfen (orders.*):

// Wildcard-Support für die can()-Methode
public function can(string $permission): bool
{
    foreach ($this->userPermissions as $granted) {
        if ($granted === $permission) return true;
        // Wildcard: 'orders.*' matcht 'orders.view', 'orders.delete' etc.
        if (str_ends_with($granted, '.*')) {
            $prefix = substr($granted, 0, -1); // 'orders.'
            if (str_starts_with($permission, $prefix)) return true;
        }
    }
    return false;
}

Middleware: Berechtigungsprüfung zentralisieren

Berechtigungen gehören nicht in jeden Controller einzeln, sondern in eine zentrale Middleware:

// Route-basierte Berechtigungsprüfung
$routes = [
    'GET  /orders'        => ['handler' => 'OrderController@index',  'permission' => 'orders.view'],
    'POST /orders'        => ['handler' => 'OrderController@create', 'permission' => 'orders.create'],
    'DELETE /orders/{id}' => ['handler' => 'OrderController@delete', 'permission' => 'orders.delete'],
];

// Middleware prüft Berechtigung vor dem Handler
function checkPermission(array $route, Authorization $auth): void
{
    if (isset($route['permission']) && !$auth->can($route['permission'])) {
        http_response_code(403);
        exit(json_encode(['error' => 'Keine Berechtigung für diese Aktion']));
    }
}

// Router ruft Middleware auf, dann Handler
foreach ($routes as $pattern => $config) {
    if (matchRoute($requestMethod . ' ' . $requestUri, $pattern)) {
        checkPermission($config, $auth);
        call_user_func($config['handler']);
    }
}

In echten Projekten übernimmt das Router-Framework die Middleware-Kette (Slim, Symfony, Laravel). Das Beispiel zeigt das Prinzip.

Wichtig: Die Middleware ist die erste Verteidigungslinie, nicht die einzige. Bei datensatzbezogenen Rechten (z.B. “nur eigene Aufträge bearbeiten”) muss zusätzlich im Service-Layer geprüft werden.

Row-Level Security: “Nur meine Daten”

Die häufigste Anforderung jenseits von RBAC: Nutzer dürfen nur Datensätze sehen, die ihnen gehören oder ihrem Team zugeordnet sind.

class OrderRepository
{
    public function findAccessible(int $userId, Authorization $auth): array
    {
        // Admin: alles sehen
        if ($auth->can('orders.view_all')) {
            return $this->findAll();
        }

        // Teamleiter: Team-Aufträge sehen
        if ($auth->can('orders.view_team')) {
            $teamMembers = $this->getTeamMemberIds($userId);
            $placeholders = implode(',', array_fill(0, count($teamMembers), '?'));
            $stmt = $this->db->prepare(
                "SELECT * FROM orders WHERE created_by IN ($placeholders) ORDER BY created_at DESC"
            );
            $stmt->execute($teamMembers);
            return $stmt->fetchAll();
        }

        // Standard: nur eigene Aufträge
        $stmt = $this->db->prepare(
            "SELECT * FROM orders WHERE created_by = ? ORDER BY created_at DESC"
        );
        $stmt->execute([$userId]);
        return $stmt->fetchAll();
    }
}

PostgreSQL bietet Row-Level Security (RLS) nativ - damit wird die Filterung auf Datenbankebene erzwungen, nicht nur in der Anwendung:

Voraussetzung ist eine Session-Variable (z.B. app.user_id), die bei jeder Datenbankverbindung gesetzt wird:

-- RLS aktivieren und Policy definieren
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY user_orders ON orders
    FOR SELECT
    USING (created_by = current_setting('app.user_id')::int);
// PHP: User-ID bei jeder DB-Verbindung setzen
$db->exec("SET app.user_id = " . (int)$currentUser->id);
// Ab hier filtert PostgreSQL automatisch - auch bei SELECT *

Das ist sicherer, weil auch direkte DB-Zugriffe (Reporting-Tools, Adminer) die Regeln einhalten. Aber: RLS verschiebt Sicherheitslogik in die Datenbank. Das ist stark, macht Debugging, lokale Entwicklung und Reporting aber oft anspruchsvoller. Lohnt sich vor allem bei hohen Sicherheitsanforderungen.

Wichtig bei Connection-Pooling oder langlebigen Worker-Prozessen: Der User-Kontext (SET app.user_id) muss pro Request sauber neu gesetzt werden. Wird eine Verbindung aus dem Pool wiederverwendet, klebt sonst die User-ID des vorherigen Requests daran – mit potenziell fatalen Folgen.


Häufige Fehler

1. Berechtigungen nur im Frontend prüfen

Buttons werden ausgeblendet, aber die API dahinter ist ungeschützt. Ein curl-Befehl genügt, um die Prüfung zu umgehen.

Lösung: Frontend-Prüfung ist UX (Buttons ausblenden, Menüpunkte verstecken). Backend-Prüfung ist Security. Beides ist nötig, aber nur das Backend zählt als Schutz.

2. Hardcoded Rollen statt Permissions

// Schlecht: Rolle hardcoded
if ($user->role === 'admin' || $user->role === 'teamleiter') {
    // Bericht anzeigen
}

// Besser: Permission prüfen
if ($auth->can('reports.view')) {
    // Bericht anzeigen
}

Warum: Wenn eine neue Rolle “Abteilungsleiter” dazukommt, die auch Berichte sehen soll, müssen Sie bei hardcoded Rollen jedes if-Statement im Code finden und anpassen. Bei Permissions weisen Sie der neuen Rolle einfach reports.view zu - ohne Code-Änderung.

3. Keine Default-Deny-Strategie

// Gefährlich: Nur bestimmte Aktionen sperren
if ($action === 'delete' && !$auth->can('orders.delete')) {
    deny();
}
// Alles andere ist erlaubt - auch Aktionen, die Sie vergessen haben

// Sicher: Alles sperren, nur Erlaubtes freigeben
if (!$auth->can("orders.$action")) {
    deny();
}

Prinzip: Was nicht explizit erlaubt ist, ist verboten. Nicht umgekehrt. Bei sauber zentralisierter Prüfung sind neue Features dann standardmäßig gesperrt, bis Sie die Berechtigung bewusst vergeben.

4. Berechtigungen nicht cachen

// Schlecht: Bei jeder Prüfung die DB fragen
public function can(string $permission): bool
{
    $stmt = $this->db->prepare("SELECT COUNT(*) FROM ...");
    // 20 Permission-Checks pro Request = 20 DB-Queries
}

// Besser: Einmal laden, in Session oder Memory cachen
public function loadPermissions(int $userId): void
{
    if (isset($_SESSION['permissions'])) {
        $this->userPermissions = $_SESSION['permissions'];
        return;
    }

    // DB-Query nur beim ersten Request nach Login
    $this->userPermissions = $this->fetchFromDb($userId);
    $_SESSION['permissions'] = $this->userPermissions;
}

Cache-Invalidierung: Session-Cache spart Queries, ersetzt aber keine serverseitige Invalidierungsstrategie. Wenn ein Admin Rechte ändert, einen Nutzer deaktiviert oder eine Rolle entzieht, müssen die gecachten Permissions sofort ungültig werden - nicht erst beim nächsten Login.

// Versions-Counter in der DB
// Admin ändert Rechte eines einzelnen Nutzers:
$stmt = $db->prepare(
    "UPDATE users SET permission_version = permission_version + 1 WHERE id = ?"
);
$stmt->execute([$userId]);

// Admin ändert eine Rolle → alle Nutzer mit dieser Rolle invalidieren:
$stmt = $db->prepare(
    "UPDATE users SET permission_version = permission_version + 1
     WHERE id IN (SELECT user_id FROM user_roles WHERE role_id = ?)"
);
$stmt->execute([$roleId]);

// Beim Laden: Version prüfen
public function loadPermissions(int $userId): void
{
    $dbVersion = $this->getPermissionVersion($userId);

    if (isset($_SESSION['perm_version']) && $_SESSION['perm_version'] === $dbVersion) {
        $this->userPermissions = $_SESSION['permissions'];
        return;
    }

    // Neu laden bei Version-Mismatch
    $this->userPermissions = $this->fetchFromDb($userId);
    $_SESSION['permissions'] = $this->userPermissions;
    $_SESSION['perm_version'] = $dbVersion;
}

In stateless APIs (JWT, OAuth) liegt der Cache typischerweise in Redis statt in der PHP-Session. Ein Pattern dafür:

// Redis-basierter Permission-Cache für stateless APIs
$cacheKey = "permissions:user:{$userId}";
$cached = $redis->get($cacheKey);

if ($cached !== false) {
    $permissions = json_decode($cached, true);
} else {
    $permissions = $this->fetchFromDb($userId);
    $redis->setex($cacheKey, 300, json_encode($permissions)); // 5 Min TTL
}

// Invalidierung: Beim Ändern von Rechten
$redis->del("permissions:user:{$userId}");

Deaktivierte Accounts: Bei sicherheitskritischen Anwendungen sollte ein deaktivierter Account auch bestehende Sessions und Tokens serverseitig entwerten. Bei JWTs bedeutet das: eine Blocklist (z.B. in Redis) oder kurze Token-Laufzeiten mit Refresh-Token-Widerruf.

5. Superadmin ohne Audit-Trail

Ein Admin-Account, der alles darf und dessen Aktionen nicht protokolliert werden, ist ein Sicherheitsrisiko. Gerade bei Accounts mit vollen Rechten ist nachvollziehbar besonders wichtig.

Lösung: Jede schreibende Aktion loggen - wer, was, wann, auf welchem Datensatz:

class AuditLog
{
    public function log(int $userId, string $action, string $resource, int $resourceId): void
    {
        $stmt = $this->db->prepare("
            INSERT INTO audit_log (user_id, action, resource, resource_id, ip, created_at)
            VALUES (?, ?, ?, ?, ?, NOW())
        ");
        $stmt->execute([$userId, $action, $resource, $resourceId, $_SERVER['REMOTE_ADDR']]);
    }
}

// Verwendung
$auditLog->log($currentUser->id, 'delete', 'orders', $orderId);

Mindestens für admin-relevante Aktionen: Nutzer anlegen/löschen, Rollen ändern, Daten exportieren. Audit-Logs sollten unveränderbar gespeichert werden (Append-Only-Tabelle oder getrennte Audit-Datenbank), damit sie im Nachhinein nicht manipuliert werden können.


Entscheidungshilfe: Welches Modell passt?

AnforderungModell
3-5 feste Rollen, klare HierarchieRBAC
”Nur eigene Daten sehen”RBAC + Row-Level-Filter
Berechtigungen hängen von Betrag, Zeit, Abteilung abABAC
Mehrere Kunden/Firmen in einer AppMulti-Tenancy + RBAC
Regulatorische Anforderungen (DSGVO, ISO 27001)RBAC + Audit-Log + ggf. RLS

Meine Empfehlung für die meisten Business-Apps: Starten Sie mit RBAC und resource.action-Permissions. Das deckt die allermeisten Anforderungen ab. ABAC und RLS kommen dazu, wenn konkrete Anforderungen das erfordern - nicht vorher.


Checkliste: Berechtigungen in Ihrer Anwendung

  • Modell gewählt? RBAC reicht für die meisten Fälle. ABAC nur bei kontextabhängigen Regeln
  • Backend-Prüfung vorhanden? Jede API-Route und jeder Service-Call prüft Berechtigungen - Frontend allein ist kein Schutz
  • Default Deny? Alles gesperrt, nur explizit Erlaubtes freigegeben
  • Permission-Caching? Berechtigungen einmal laden, nicht bei jedem Check die DB fragen
  • Audit-Trail? Wer hat was wann geändert - mindestens für admin-relevante Aktionen
  • Row-Level-Zugriff geklärt? Wenn Nutzer nur eigene Daten sehen sollen: im Repository filtern oder bei hohen Sicherheitsanforderungen per RLS auf Datenbankebene erzwingen
  • Rollen administrierbar? Admin-UI zum Anlegen/Bearbeiten von Rollen und Zuweisen von Permissions - keine Rollen im Code hardcoded

Berechtigungssystem geplant und unsicher, welches Modell zu Ihrer Anwendung passt? Ich analysiere Ihre Anforderungen und entwerfe ein Rechtemodell, das mit Ihrem Projekt mitwächst - ohne Over-Engineering am Anfang. Kostenloses Erstgespräch anfragen.

Carola Schulte

Über Carola Schulte

Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.

Projekt im Kopf?

Lassen Sie uns besprechen, wie ich Ihre Anforderungen umsetzen kann – kostenlos und unverbindlich.

Kostenloses Erstgespräch