DSGVO für Webapplikationen: Was wirklich nötig ist
Security & Compliance

DSGVO für Webapplikationen: Was wirklich nötig ist

Carola Schulte
17. März 2025
16 min

DSGVO für Webapplikationen: Was wirklich nötig ist

Die DSGVO ist kein Monster. Sie ist auch kein Grund, jedes Projekt mit 200 Seiten Datenschutz-Dokumentation zu erschlagen. Nach Jahren der Entwicklung von Business-Anwendungen mit personenbezogenen Daten kann ich sagen: Die meisten DSGVO-Anforderungen sind gesunder Menschenverstand - und vieles davon sollten Sie ohnehin tun.

Dieser Artikel ist für Entwickler und Entscheider, die wissen wollen, was technisch wirklich nötig ist. Keine Rechtsberatung, keine Panikmache, sondern pragmatische Umsetzung.


Was die DSGVO wirklich von Software verlangt

┌─────────────────────────────────────────────────────────────────────────────┐
│                    DSGVO-KERNANFORDERUNGEN FÜR SOFTWARE                      │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│   TECHNISCH (Ihr Job als Entwickler)                                        │
│   ├── Datensparsamkeit: Nur erheben, was nötig ist                         │
│   ├── Verschlüsselung: Transport (TLS) + Speicherung (sensible Daten)      │
│   ├── Zugriffskontrolle: Wer darf was sehen?                               │
│   ├── Löschkonzept: Daten müssen löschbar sein                             │
│   ├── Auskunftsfähigkeit: Export der Daten einer Person                    │
│   └── Audit-Trail: Wer hat wann was geändert?                              │
│                                                                              │
│   ORGANISATORISCH (Zusammenarbeit mit Datenschutz)                          │
│   ├── Verarbeitungsverzeichnis: Was wird wo gespeichert?                   │
│   ├── AVV: Verträge mit Dienstleistern (Hosting, SaaS)                     │
│   ├── TOM: Technisch-organisatorische Maßnahmen dokumentieren              │
│   └── Datenschutzerklärung: Für Endnutzer verständlich                     │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Teil 1: Datensparsamkeit - Weniger ist mehr

Das Prinzip

Erhebe nur Daten, die du für den konkreten Zweck brauchst.

Klingt trivial, wird aber ständig verletzt. Ein paar Beispiele:

┌─────────────────────────────────────────────────────────────────────────────┐
│                         DATENSPARSAMKEIT                                     │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  ❌ SCHLECHT                          ✅ BESSER                              │
│  ──────────────────────────────────────────────────────────────────────────│
│  Geburtsdatum für Newsletter          Nur E-Mail-Adresse                    │
│  Vollständige Adresse für Login       Nur E-Mail + Passwort                 │
│  Telefonnummer "falls wir Sie..."     Nur wenn wirklich angerufen wird     │
│  IP-Adresse dauerhaft speichern       IP nach 7 Tagen anonymisieren         │
│  User-Agent für "Statistik"           Aggregierte Daten ohne Personenbezug  │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Technische Umsetzung: Pflichtfelder bewusst wählen

// ❌ Zu viele Pflichtfelder
$rules = [
    'email' => 'required|email',
    'first_name' => 'required',
    'last_name' => 'required',
    'phone' => 'required',        // Wirklich nötig?
    'birthday' => 'required',     // Wozu?
    'company' => 'required',      // Brauchen wir das?
];

// ✅ Nur was wirklich nötig ist
$rules = [
    'email' => 'required|email',
    'display_name' => 'required|min:2',  // Wie sollen wir Sie ansprechen?
    // Rest optional oder gar nicht erst im Formular
];

IP-Adressen anonymisieren

Für Logs und Statistiken reicht oft eine anonymisierte IP:

function anonymizeIp(string $ip): string
{
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
        // 192.168.1.123 → 192.168.1.0 (/24 maskiert)
        return preg_replace('/\.\d+$/', '.0', $ip);
    }

    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
        // IPv6 anonymisieren ist tricky (::1, 2001:db8::1, etc.)
        // → In der Praxis: Library nutzen oder IPv6 komplett weglassen
        return '::';  // Oder: inet_ntop(inet_pton($ip) & pack('H*', 'ffffffffffffffff0000000000000000'))
    }

    return '0.0.0.0';
}

// Hinweis: Auch gekürzte IPs können personenbezogen sein (wenn
// kombinierbar mit Timestamp etc.) - aber das Risiko sinkt deutlich.

// In Access-Logs
$log->info('Page view', [
    'path' => $request->getPath(),
    'ip' => anonymizeIp($request->getClientIp()),
]);

Teil 2: Verschlüsselung - Transport und Speicherung

Transport: TLS ist Pflicht

Das sollte 2025 selbstverständlich sein:

  • HTTPS überall - keine Ausnahmen
  • TLS 1.2+ - ältere Versionen deaktivieren
  • HSTS - Browser zwingen, HTTPS zu nutzen
# Nginx: HTTPS erzwingen
server {
    listen 80;
    server_name app.example.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;

    # HSTS (nach erfolgreichem Test aktivieren)
    add_header Strict-Transport-Security "max-age=31536000" always;

    # ...
}

Speicherung: Was muss verschlüsselt werden?

Nicht alles muss verschlüsselt gespeichert werden. Aber manches schon:

┌─────────────────────────────────────────────────────────────────────────────┐
│                    VERSCHLÜSSELUNG BEI SPEICHERUNG                           │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  MUSS verschlüsselt/gehasht:                                                │
│  ├── Passwörter (bcrypt/Argon2, NIEMALS Klartext oder MD5!)                │
│  ├── Kreditkartendaten (besser: gar nicht speichern, Payment-Provider)     │
│  ├── Gesundheitsdaten (Art. 9 DSGVO - besondere Kategorie)                 │
│  └── Tokens, API-Keys, Secrets                                              │
│                                                                              │
│  SOLLTE verschlüsselt (je nach Risiko):                                     │
│  ├── Personalausweisnummern                                                 │
│  ├── Bankverbindungen (IBAN)                                                │
│  └── Detaillierte Standortdaten                                             │
│                                                                              │
│  MUSS NICHT verschlüsselt (aber geschützt):                                 │
│  ├── Name, E-Mail, Adresse (Zugriffskontrolle reicht)                      │
│  ├── Bestellhistorie                                                        │
│  └── Normale Geschäftsdaten                                                 │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Verschlüsselung in PHP

Für Daten, die wieder entschlüsselt werden müssen:

class FieldEncryption
{
    private string $key;

    public function __construct(string $base64Key)
    {
        // Key MUSS exakt 32 Bytes (256 bit) sein
        $this->key = base64_decode($base64Key, true);

        if ($this->key === false || strlen($this->key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
            throw new InvalidArgumentException(
                'Invalid key (use: base64_encode(random_bytes(32)))'
            );
        }
    }

    // Key generieren: echo base64_encode(random_bytes(32));
    // Oder via Argon2: sodium_crypto_pwhash() für passwort-basierte Keys

    public function encrypt(string $plaintext): string
    {
        $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->key);

        // Nonce + Ciphertext als Base64
        return base64_encode($nonce . $ciphertext);
    }

    public function decrypt(string $encrypted): string
    {
        $decoded = base64_decode($encrypted, true);

        if ($decoded === false || strlen($decoded) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
            throw new DecryptionException('Invalid payload');
        }

        $nonce = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $ciphertext = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

        $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key);

        if ($plaintext === false) {
            throw new DecryptionException('Decryption failed');
        }

        return $plaintext;
    }
}

// Verwendung
$crypto = new FieldEncryption($_ENV['ENCRYPTION_KEY']);

// Speichern
$user->iban_encrypted = $crypto->encrypt($iban);

// Lesen
$iban = $crypto->decrypt($user->iban_encrypted);

Passwörter: Immer hashen, nie verschlüsseln

// ✅ RICHTIG: password_hash mit Argon2
$hash = password_hash($password, PASSWORD_ARGON2ID);

// Verifizieren
if (password_verify($inputPassword, $storedHash)) {
    // Login erfolgreich
}

// ❌ FALSCH: Verschlüsselung (kann entschlüsselt werden!)
// ❌ FALSCH: MD5, SHA1 (zu schnell, Rainbow Tables)
// ❌ FALSCH: Eigene "Verschlüsselung"

Teil 3: Zugriffskontrolle - Wer darf was sehen?

Grundprinzip: Least Privilege

Jeder Nutzer sieht nur, was er für seine Aufgabe braucht:

class CustomerController
{
    public function show(int $customerId): Response
    {
        $customer = $this->customerRepository->find($customerId);

        // Zugriffskontrolle: Darf dieser User diesen Kunden sehen?
        if (!$this->canAccess($customer)) {
            throw new AccessDeniedException();
        }

        return $this->render('customer/show', ['customer' => $customer]);
    }

    private function canAccess(Customer $customer): bool
    {
        $user = $this->getCurrentUser();

        // Admin sieht alles
        if ($user->hasRole('ADMIN')) {
            return true;
        }

        // Vertrieb sieht nur eigene Kunden
        if ($user->hasRole('SALES')) {
            return $customer->getSalesRepId() === $user->getId();
        }

        // Kunde sieht nur sich selbst
        if ($user->hasRole('CUSTOMER')) {
            return $customer->getUserId() === $user->getId();
        }

        return false;
    }
}

Multi-Tenant: Mandantentrennung

Bei SaaS-Anwendungen ist Mandantentrennung kritisch:

// Globaler Scope auf alle Queries
class TenantScope
{
    public function apply(QueryBuilder $query, string $table): void
    {
        $tenantId = $this->getCurrentTenantId();

        if ($tenantId === null) {
            throw new NoTenantException('No tenant context');
        }

        $query->andWhere("$table.tenant_id = :tenant_id")
              ->setParameter('tenant_id', $tenantId);
    }
}

// Repository mit automatischem Tenant-Filter
class CustomerRepository
{
    public function findAll(): array
    {
        $qb = $this->createQueryBuilder('c');
        $this->tenantScope->apply($qb, 'c');

        return $qb->getQuery()->getResult();
    }

    // NIEMALS: findAll() ohne Tenant-Filter!
}

Datenbank-Ebene: Row-Level Security (PostgreSQL)

Zusätzliche Absicherung direkt in der Datenbank:

-- Policy: User sieht nur eigene Tenant-Daten
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON customers
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- In der Anwendung: Pro Connection/Transaktion setzen!
SET LOCAL app.tenant_id = 'a1b2c3d4-...';  -- LOCAL = nur für diese Transaktion

Achtung bei Connection-Pooling: SET LOCAL innerhalb einer Transaktion ist sauberer als SET – sonst bleibt das Setting bei Pools gerne mal hängen. Bei PgBouncer/pgpool: Session-Mode oder SET bei jedem Checkout. Das ist der Klassiker, der RLS kaputtmacht.


Teil 4: Löschkonzept - Daten müssen weg können

Recht auf Löschung (Art. 17 DSGVO)

Betroffene können die Löschung ihrer Daten verlangen. Das muss technisch möglich sein.

┌─────────────────────────────────────────────────────────────────────────────┐
│                         LÖSCHKONZEPT                                         │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  SOFORT LÖSCHEN (auf Anfrage):                                              │
│  ├── Profildaten (Name, Adresse, Kontakt)                                  │
│  ├── Login-Credentials                                                      │
│  ├── Präferenzen, Einstellungen                                            │
│  └── Nicht mehr benötigte Dokumente                                         │
│                                                                              │
│  AUFBEWAHRUNGSPFLICHTEN BEACHTEN:                                           │
│  ├── Rechnungen: 10 Jahre (§ 147 AO) → Anonymisieren, nicht löschen        │
│  ├── Buchungsbelege: 10 Jahre                                               │
│  ├── Verträge: Je nach Verjährung (oft 3-10 Jahre)                         │
│  └── Steuerrelevante Daten: 10 Jahre                                        │
│                                                                              │
│  ANONYMISIEREN STATT LÖSCHEN:                                               │
│  ├── Bestellungen: Kundenbezug entfernen, Umsatzdaten behalten             │
│  ├── Logs: Personenbezug entfernen nach X Tagen                            │
│  └── Statistiken: Nur aggregierte Daten behalten                           │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Technische Umsetzung: Soft-Delete + Anonymisierung

class UserDeletionService
{
    public function deleteUser(User $user): void
    {
        // 1. Sofort löschbare Daten
        $user->setEmail(null);
        $user->setPhone(null);
        $user->setPasswordHash(null);
        $user->setProfilePicture(null);

        // 2. Anonymisieren statt löschen (für Statistiken)
        $user->setFirstName('Gelöscht');
        $user->setLastName('Nutzer');

        // 3. Status setzen
        $user->setDeletedAt(new DateTimeImmutable());
        $user->setAnonymizedAt(new DateTimeImmutable());

        // 4. Verknüpfte Daten behandeln
        $this->anonymizeOrders($user);
        $this->deleteDocuments($user);
        $this->removeFromMailingLists($user);

        // 5. Externe Systeme benachrichtigen
        $this->eventDispatcher->dispatch(new UserDeletedEvent($user->getId()));

        $this->entityManager->flush();

        // 6. Audit-Log (ohne personenbezogene Daten!)
        $this->auditLog->log('user_deleted', [
            'user_id' => $user->getId(),
            'deleted_by' => $this->getCurrentUser()->getId(),
        ]);
    }

    private function anonymizeOrders(User $user): void
    {
        // Rechnungen müssen 10 Jahre aufbewahrt werden
        // → Personenbezug entfernen, Beleg behalten

        foreach ($user->getOrders() as $order) {
            $order->setCustomerName('Anonymisiert');
            $order->setCustomerEmail(null);
            $order->setShippingAddress(null);
            // Rechnungsdaten (Summe, Datum, Steuern) bleiben erhalten
        }
    }
}

Automatische Löschfristen

class DataRetentionService
{
    private array $retentionPolicies = [
        'access_logs' => '90 days',
        'session_data' => '30 days',
        'password_reset_tokens' => '24 hours',
        'deleted_users' => '30 days',      // Nach Anonymisierung
        'inactive_accounts' => '2 years',   // Warnung vorher!
    ];

    public function cleanup(): void
    {
        // Access-Logs älter als 90 Tage
        $this->db->execute(
            "DELETE FROM access_logs WHERE created_at < NOW() - INTERVAL '90 days'"
        );

        // Abgelaufene Tokens
        $this->db->execute(
            "DELETE FROM password_reset_tokens WHERE expires_at < NOW()"
        );

        // Bereits anonymisierte User endgültig löschen
        // ACHTUNG: Nur wenn keine FK-Constraints brechen und
        // keine Aufbewahrungspflichten mehr greifen!
        // In vielen Systemen ist Hard-Delete unrealistisch – Standard ist
        // Anonymisierung + Deaktivierung; Hard-Delete nur wenn Datenmodell darauf ausgelegt.
        $deletedCount = (int) $this->db->executeStatement(
            "DELETE FROM users
             WHERE anonymized_at IS NOT NULL
             AND anonymized_at < NOW() - INTERVAL '30 days'
             AND NOT EXISTS (
                 SELECT 1 FROM invoices i
                 WHERE i.user_id = users.id
                 AND i.created_at > NOW() - INTERVAL '10 years'
             )"
        );

        // Löschungen dokumentieren (für Nachweis)
        $this->auditLog->log('retention_cleanup_completed', [
            'deleted_count' => $deletedCount,
            'run_at' => (new DateTimeImmutable())->format(DATE_ATOM),
        ]);
    }
}

// Cron: Täglich ausführen
// 0 3 * * * php bin/console app:data-retention

Teil 5: Auskunftsrecht - Datenexport

Recht auf Auskunft (Art. 15 DSGVO)

Betroffene können eine Kopie ihrer Daten verlangen. Das muss technisch umsetzbar sein.

class DataExportService
{
    public function exportUserData(User $user): array
    {
        return [
            'profile' => [
                'email' => $user->getEmail(),
                'name' => $user->getFullName(),
                'created_at' => $user->getCreatedAt()->format('c'),
                'last_login' => $user->getLastLoginAt()?->format('c'),
            ],

            'addresses' => array_map(
                fn($a) => [
                    'street' => $a->getStreet(),
                    'city' => $a->getCity(),
                    'postal_code' => $a->getPostalCode(),
                ],
                $user->getAddresses()
            ),

            'orders' => array_map(
                fn($o) => [
                    'order_number' => $o->getNumber(),
                    'date' => $o->getCreatedAt()->format('c'),
                    'total' => $o->getTotal(),
                    'items' => $o->getItemsSummary(),
                ],
                $user->getOrders()
            ),

            'consents' => array_map(
                fn($c) => [
                    'type' => $c->getType(),
                    'given_at' => $c->getGivenAt()->format('c'),
                    'ip_address' => $c->getIpAddress(),
                ],
                $user->getConsents()
            ),

            'access_logs' => $this->getRecentAccessLogs($user),
        ];
    }

    public function exportAsJson(User $user): string
    {
        return json_encode(
            $this->exportUserData($user),
            JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE
        );
    }
}

Export-Endpunkt

class ProfileController
{
    #[Route('/profile/export', methods: ['GET'])]
    public function exportData(): Response
    {
        $user = $this->getUser();

        // Rate-Limiting: Max 1 Export pro Tag
        if ($this->hasExportedToday($user)) {
            throw new TooManyRequestsException('Max 1 Export pro Tag');
        }

        $data = $this->dataExportService->exportAsJson($user);

        // Audit-Log
        $this->auditLog->log('data_export_requested', [
            'user_id' => $user->getId(),
        ]);

        return new Response($data, 200, [
            'Content-Type' => 'application/json',
            'Content-Disposition' => 'attachment; filename="meine-daten.json"',
        ]);
    }
}

Teil 6: Audit-Trail - Wer hat was geändert?

Warum Audit-Logs?

  • Rechenschaftspflicht (Art. 5 DSGVO): Sie müssen nachweisen können, dass Sie compliant sind
  • Incident Response: Bei Datenpannen wissen, was passiert ist
  • Interne Kontrolle: Missbrauch erkennen

Was loggen?

┌─────────────────────────────────────────────────────────────────────────────┐
│                         AUDIT-LOG EVENTS                                     │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  MUSS geloggt werden:                                                       │
│  ├── Login/Logout (wer, wann, von wo)                                      │
│  ├── Passwort-Änderungen                                                    │
│  ├── Berechtigungsänderungen (Rollen, Rechte)                              │
│  ├── Zugriff auf sensible Daten (Personalakten, Gehälter)                  │
│  ├── Datenexport (Art. 15 Anfragen)                                        │
│  ├── Datenlöschung (Art. 17 Anfragen)                                      │
│  └── Admin-Aktionen (User anlegen/sperren)                                 │
│                                                                              │
│  SOLLTE geloggt werden:                                                     │
│  ├── Änderungen an Stammdaten                                              │
│  ├── Bulk-Operationen                                                       │
│  └── Fehlgeschlagene Zugriffsversuche                                      │
│                                                                              │
│  NICHT loggen (oder anonymisiert):                                          │
│  ├── Normale Seitenaufrufe (→ Access-Log, anonymisiert)                    │
│  ├── Passwörter (auch nicht gehasht!)                                      │
│  └── Sensible Daten im Klartext                                            │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Technische Umsetzung

class AuditLogger
{
    public function log(string $action, array $context = []): void
    {
        $entry = [
            'id' => Uuid::v7(),
            'timestamp' => new DateTimeImmutable(),
            'action' => $action,
            'user_id' => $this->getCurrentUserId(),
            'ip_address' => $this->getClientIp(),
            'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
            'context' => $context,
        ];

        // In separate Tabelle (oder externes System)
        $this->auditRepository->save($entry);
    }
}

// Verwendung
$this->auditLog->log('user.login', [
    'method' => '2fa',
]);

$this->auditLog->log('customer.viewed', [
    'customer_id' => $customer->getId(),
]);

$this->auditLog->log('salary.exported', [
    'employee_ids' => $employeeIds,
    'export_format' => 'xlsx',
]);

Audit-Logs schützen

-- Audit-Tabelle: Partitioniert für Performance + Retention
CREATE TABLE audit_log (
    id UUID PRIMARY KEY,
    timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    action VARCHAR(100) NOT NULL,
    user_id INT,
    ip_address INET,
    context JSONB
) PARTITION BY RANGE (timestamp);

-- Quartals-Partitionen
CREATE TABLE audit_log_2025_q1 PARTITION OF audit_log
    FOR VALUES FROM ('2025-01-01') TO ('2025-04-01');
CREATE TABLE audit_log_2025_q2 PARTITION OF audit_log
    FOR VALUES FROM ('2025-04-01') TO ('2025-07-01');

-- Keine UPDATE/DELETE-Rechte für App-User
REVOKE UPDATE, DELETE ON audit_log FROM app_user;

-- Alte Partitionen nach Retention-Frist droppen (z.B. nach 2 Jahren)
-- DROP TABLE audit_log_2023_q1;

-- Hinweis: Bei Partitionen ggf. Rechte/Owner auf allen Partitionen prüfen

Teil 7: Einwilligungen verwalten

class ConsentService
{
    public function giveConsent(User $user, string $type, string $text): void
    {
        $consent = new Consent();
        $consent->setUser($user);
        $consent->setType($type);  // 'marketing', 'newsletter', 'profiling'
        $consent->setConsentText($text);  // Exakter Wortlaut zum Zeitpunkt
        $consent->setGivenAt(new DateTimeImmutable());
        // IP speichern für Nachweis (gekürzt reicht oft für den Zweck)
        $consent->setIpAddress(IpAnonymizer::anonymize($this->getClientIp()));
        $consent->setUserAgent($_SERVER['HTTP_USER_AGENT'] ?? null);

        // Consent-Logs: Retention beachten (z.B. 3 Jahre nach Widerruf)
        $this->entityManager->persist($consent);
        $this->entityManager->flush();
    }

    public function withdrawConsent(User $user, string $type): void
    {
        $consent = $this->consentRepository->findActiveConsent($user, $type);

        if ($consent) {
            $consent->setWithdrawnAt(new DateTimeImmutable());
            $this->entityManager->flush();

            // Konsequenzen umsetzen
            $this->eventDispatcher->dispatch(
                new ConsentWithdrawnEvent($user, $type)
            );
        }
    }

    public function hasActiveConsent(User $user, string $type): bool
    {
        $consent = $this->consentRepository->findActiveConsent($user, $type);
        return $consent !== null && $consent->getWithdrawnAt() === null;
    }
}

Double-Opt-In für Newsletter

class NewsletterService
{
    public function subscribe(string $email): void
    {
        // 1. Pending-Eintrag erstellen
        $subscription = new NewsletterSubscription();
        $subscription->setEmail($email);
        $subscription->setToken(bin2hex(random_bytes(32)));
        $subscription->setStatus('pending');
        $subscription->setRequestedAt(new DateTimeImmutable());
        // IP anonymisieren (oder roh speichern wenn DSB Nachweis-IP verlangt)
        $subscription->setIpAddress(IpAnonymizer::anonymize($this->getClientIp()));

        $this->entityManager->persist($subscription);
        $this->entityManager->flush();

        // 2. Bestätigungs-Mail senden
        $this->mailer->send(new ConfirmSubscriptionEmail($subscription));
    }

    public function confirm(string $token): void
    {
        $subscription = $this->repository->findByToken($token);

        if (!$subscription || $subscription->isExpired()) {
            throw new InvalidTokenException();
        }

        $subscription->setStatus('confirmed');
        $subscription->setConfirmedAt(new DateTimeImmutable());

        $this->entityManager->flush();
    }
}

Teil 8: AVV und Dienstleister

Was ist ein AVV?

Ein Auftragsverarbeitungsvertrag (Art. 28 DSGVO) regelt, wie externe Dienstleister mit personenbezogenen Daten umgehen dürfen.

┌─────────────────────────────────────────────────────────────────────────────┐
│                         AVV BENÖTIGT FÜR:                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  ✓ Hosting-Provider (Hetzner, AWS, etc.)                                   │
│  ✓ E-Mail-Service (Mailgun, SendGrid, etc.)                                │
│  ✓ Cloud-Speicher (wenn personenbezogene Daten)                            │
│  ✓ Analyse-Tools (wenn nicht anonymisiert)                                 │
│  ✓ CRM/Helpdesk-Systeme                                                    │
│  ✓ Payment-Provider                                                         │
│  ✓ Backup-Dienste                                                          │
│                                                                              │
│  Die meisten großen Anbieter haben Standard-AVVs.                          │
│  → Hetzner: https://www.hetzner.com/rechtliches/avv                        │
│  → AWS: AWS Data Processing Addendum                                        │
│  → Google: Google Cloud DPA                                                 │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Technische Konsequenzen

// Beispiel: E-Mail-Versand nur über Anbieter mit AVV
class MailerFactory
{
    public function create(): MailerInterface
    {
        // ✅ Anbieter mit AVV
        return new SendgridMailer($_ENV['SENDGRID_API_KEY']);

        // ❌ Nicht: Irgendein SMTP ohne Vertrag
    }
}

// Beispiel: Keine Daten in unsichere Drittländer
class AnalyticsService
{
    public function track(string $event, array $data): void
    {
        // ✅ EU-basiert oder mit Standard-Contractual-Clauses
        $this->plausible->track($event, $data);

        // ⚠️ Problematisch ohne weitere Maßnahmen:
        // Google Analytics, wenn Server in USA
    }
}

Teil 9: Checkliste für den Go-Live

┌─────────────────────────────────────────────────────────────────────────────┐
│                    DSGVO-CHECKLISTE VOR GO-LIVE                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  TECHNISCH                                                                   │
│  ☐ HTTPS überall, TLS 1.2+                                                 │
│  ☐ Passwörter mit bcrypt/Argon2 gehasht                                    │
│  ☐ Sensible Daten verschlüsselt (wo nötig)                                 │
│  ☐ Zugriffskontrolle implementiert                                         │
│  ☐ Mandantentrennung (bei Multi-Tenant)                                    │
│  ☐ Löschfunktion vorhanden und getestet                                    │
│  ☐ Datenexport möglich (JSON/CSV)                                          │
│  ☐ Audit-Log aktiv                                                          │
│  ☐ Automatische Löschfristen implementiert                                 │
│  ☐ Double-Opt-In für Newsletter                                            │
│  ☐ Cookie-Banner (wenn Cookies außer technisch notwendigen)                │
│                                                                              │
│  DOKUMENTATION                                                               │
│  ☐ Verarbeitungsverzeichnis erstellt                                       │
│  ☐ TOM (Technisch-Organisatorische Maßnahmen) dokumentiert                 │
│  ☐ Datenschutzerklärung vorhanden                                          │
│  ☐ AVVs mit allen Dienstleistern abgeschlossen                             │
│  ☐ Prozess für Betroffenenanfragen definiert                               │
│                                                                              │
│  ORGANISATION                                                                │
│  ☐ Datenschutzbeauftragter prüfen (§ 38 BDSG: kann ab 20 MA erforderlich sein) │
│  ☐ Mitarbeiter geschult                                                    │
│  ☐ Incident-Response-Prozess definiert                                     │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Fazit: DSGVO ist kein Hexenwerk

Die DSGVO verlangt im Kern nichts Unmögliches:

  1. Nur sammeln, was nötig ist - Datensparsamkeit
  2. Daten schützen - Verschlüsselung, Zugriffskontrolle
  3. Löschen können - Technisch und organisatorisch
  4. Transparent sein - Auskunft geben, dokumentieren
  5. Verantwortung übernehmen - AVVs, Audit-Trails

Das meiste davon ist gute Software-Entwicklung. Wer sauber arbeitet, hat mit der DSGVO wenig Probleme.

Was Sie nicht brauchen:

  • Teure “DSGVO-Zertifizierungen”
  • 500-seitige Datenschutzkonzepte
  • Panik vor jedem Cookie

Was Sie brauchen:

  • Einen Überblick, welche Daten Sie verarbeiten
  • Technische Maßnahmen, die zum Risiko passen
  • Einen Plan für Betroffenenanfragen
  • Pragmatismus statt Perfektionismus

Disclaimer und Weiterführende Beratung

Dieser Artikel ersetzt keine Rechtsberatung. Für die rechtliche Einordnung und die Tätigkeit als externer Datenschutzbeauftragter steht Thorben Schulte, DSGVO-Datenschutzbeauftragter mit 10 Jahren Erfahrung, zur Verfügung.

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