Identität und Kryptografie-Grundlagen: AuthN vs. AuthZ, Sessions, Tokens, JWT, OAuth2 mit PKCE, Passwort-Hashing

Track Konzepte · Security · ca. 70 Min.

Worum es geht

Fast jede Anwendung, die du als Freelancer baust, hat einen Login. Fast jeder Sicherheitsvorfall in solchen Anwendungen hat mit Identität zu tun: ein gefälschtes Token wird akzeptiert, ein abgefangener Code wird eingelöst, eine Passwort-Datenbank wird geleakt und die Passwörter sind in Minuten geknackt. Die gute Nachricht: Die Bausteine sind klein, und du kannst sie in Python nachbauen, um sie zu verstehen. (Im Projekt baust du sie nicht selbst, dazu mehr in der Falle.)

Am Ende der Lektion kannst du Authentifizierung (authentication, AuthN) und Autorisierung (authorization, AuthZ) trennen, Sessions und Tokens gegeneinander abwägen, ein JWT (JSON Web Token) mit HS256 selbst prüfen und seine Fallstricke benennen, den Authorization Code Flow mit PKCE (Proof Key for Code Exchange) durchrechnen, ein Passwort mit Salt und Iterationen speichern und Refresh-Token-Rotation mit Reuse Detection (Wiederverwendungs-Erkennung) bauen. Der Prüfstein der Lektion: Warum Authorization Code mit PKCE statt Implicit Flow?

Plane ehrlich 70 Minuten ein: etwa 30 Minuten Lesen, 40 Minuten Übungen (fünf Stück). Eine gute Pause ist nach Übung 2.

Alles läuft im Browser mit hmac, hashlib, base64, json und secrets. Es gibt keine echten Server. Kleine Hinweise vorab:

  • Die Beispiele sind Lernmodelle. Produktionscode nimmt geprüfte Bibliotheken.
  • Das Passwort-Hashing hier nutzt nur 1000 Iterationen, damit es im Browser schnell bleibt. Das ist ein Demo-Wert, kein Empfehlungswert.

Von JS/TS her gedacht

Konzept JS/TS Python (hier)
Signatur (HMAC) crypto.createHmac("sha256", key) hmac.new(key, daten, hashlib.sha256)
Konstantzeit-Vergleich crypto.timingSafeEqual(a, b) hmac.compare_digest(a, b)
base64url Buffer.from(x).toString("base64url") base64.urlsafe_b64encode(x) plus = abschneiden
Sichere Zufallszahl crypto.randomBytes(32) secrets.token_bytes(32), als URL-taugliche Zeichenkette secrets.token_urlsafe(32) (ergibt 43 Zeichen)
JWT in der Praxis Bibliothek jsonwebtoken, jose Bibliothek PyJWT, authlib
Passwort-Hashing in der Praxis bcrypt, argon2 (npm) argon2-cffi, bcrypt

Der Beweis, dass das Konzept sprachunabhängig ist: Dasselbe JWT, in Node gebaut.

const crypto = require("crypto");
const b64url = (b) => Buffer.from(b).toString("base64url");
const kopf = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const inhalt = b64url(JSON.stringify({ sub: "anna", rolle: "leser", exp: 1700000600 }));
const sig = crypto.createHmac("sha256", "geheimnis-nur-auf-dem-server").update(`${kopf}.${inhalt}`).digest();
console.log(`${kopf}.${inhalt}.${b64url(sig)}`);

Ausgabe:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbm5hIiwicm9sbGUiOiJsZXNlciIsImV4cCI6MTcwMDAwMDYwMH0.f8Qs6lTw1sSvXjEqeStLqKCnUsvPmz5V7VzHUgqmU78

Gleich siehst du, dass Python exakt dieselbe Zeichenkette erzeugt.

Konzept

Schritt 1: Wer bist du, und was darfst du?

Zwei Fragen, die oft vermischt werden:

Authentifizierung (AuthN) Autorisierung (AuthZ)
Frage Wer bist du? Was darfst du hier tun?
Beweis Passwort, Passkey, Einmalcode, Token Rolle, Rechte, Besitz der Ressource
Wann Beim Login, dann über Session oder Token Bei jedem Request an eine Ressource
HTTP bei Fehler 401 (nicht angemeldet oder Beweis ungültig) 403 (angemeldet, aber nicht erlaubt)

Beispiel: Anna ist angemeldet (AuthN erfolgreich). Sie ruft GET /rechnungen/4711 auf. Das Backend muss prüfen, ob Rechnung 4711 Anna gehört (AuthZ). Sonst kann jeder angemeldete Nutzer fremde Rechnungen lesen, nur indem er die Nummer ändert. Diese Lücke heißt Broken Access Control und gehört zu den Schwerpunkten der OWASP Top 10 (Quelle: Grundlagen, Schicht 8, Angriffe; Platz in der aktuellen Liste: bitte prüfen).

Daraus folgt eine Regel, die auch den Prüfstein der Schicht beantwortet: Eingabevalidierung im Frontend reicht nicht, weil der Angreifer das Frontend nicht benutzen muss. Er schickt Requests direkt mit curl. Autorisierung gehört darum ins Backend, in jeden Request, nie nur in versteckte Buttons.

Schritt 2: Sessions oder Tokens

Nach dem Login muss sich der Server merken, wer du bist. Zwei Wege:

  • Session (serverseitiger Zustand): Der Server legt einen Eintrag an (session_id -> user) und schickt nur die zufällige Session-ID als Cookie. Bei jedem Request schlägt er die ID nach.
  • Token (clientseitiger Zustand): Der Server schickt ein selbstbeschreibendes Token (z. B. ein JWT) mit der Identität darin, signiert. Bei jedem Request prüft er nur die Signatur, ohne Nachschlagen.
Session Token (JWT)
Wo steht der Zustand? Im Server (Store, z. B. Datenbank oder Redis) Im Token beim Client
Sofort sperrbar (Logout, Mitarbeiter entlassen)? Ja, Eintrag löschen Nein, nur bis zum Ablauf (exp), außer du baust wieder einen Store dazu
Skalierung auf viele Server Gemeinsamer Store nötig Jeder Server prüft allein mit Secret oder Public Key
Drittparteien (andere Services, Mobile Apps) Umständlich Passt gut
Größe pro Request Klein (eine ID) Größer (Daten plus Signatur)

Beides ist legitim. Für ein klassisches Web-Backend mit Browser ist die Session mit HttpOnly-, Secure- und SameSite-Cookie oft die einfachere und sicherere Wahl. Tokens lohnen sich, wenn mehrere Services oder Clients ohne gemeinsamen Store prüfen sollen. Das ist ein klassischer Trade-off (Übung 5).

Schritt 3: Aufbau eines JWT, durchgerechnet

Ein JWT besteht aus drei Teilen, getrennt durch Punkte: kopf.inhalt.signatur. Kopf (Header) und Inhalt (Payload) sind JSON, jeweils in base64url kodiert (wie base64, aber mit - und _ statt + und / und ohne = am Ende). Die Signatur ist bei HS256 ein HMAC-SHA256 (Hash-based Message Authentication Code) über den Text kopf.inhalt, mit einem Secret, das nur der Server kennt.

Ausgabe:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbm5hIiwicm9sbGUiOiJsZXNlciIsImV4cCI6MTcwMDAwMDYwMH0.f8Qs6lTw1sSvXjEqeStLqKCnUsvPmz5V7VzHUgqmU78
b'{"alg":"HS256","typ":"JWT"}'
{'sub': 'anna', 'rolle': 'leser', 'exp': 1700000600}
Signatur in Bytes: 32

Das ist dieselbe Zeichenkette wie in Node. Drei Dinge fallen auf:

  1. Der Inhalt ist nicht geheim. Jeder kann ihn dekodieren, ohne das Secret. Ein JWT ist signiert (Manipulation fällt auf), aber nicht verschlüsselt. Lege keine Passwörter oder vertrauliche Daten hinein.
  2. sub (subject, Nutzerkennung) und exp (expiration time, Ablaufzeitpunkt als Unix-Sekunden) sind registrierte Claims (Felder im Payload). exp ist der Grund, warum ein gestohlenes Token nicht ewig lebt.
  3. Der Header sagt, welchen Algorithmus der Aussteller benutzt hat. Das ist der Ursprung der berüchtigtsten Falle (nächster Schritt).

Schritt 4: Ein JWT richtig prüfen (und was alles schiefgeht)

Wer ein Token prüft, geht immer in dieser Reihenfolge vor. Erst wenn alle Schritte bestanden sind, darf er dem Inhalt trauen:

  1. Form: Genau drei Teile, jeder dekodierbar, Header und Payload sind JSON. Sonst: ungültig.
  2. Algorithmus aus der Allowlist: Dein Server legt fest, welcher Algorithmus akzeptiert wird (hier nur HS256), nicht das Token. Alles andere: ungültig.
  3. Signatur neu berechnen über kopf.inhalt mit deinem Secret und mit dem erhaltenen Wert konstantzeitig vergleichen (hmac.compare_digest).
  4. Erst jetzt den Payload auswerten: exp prüfen. Das Token ist gültig, solange jetzt < exp. Bei jetzt == exp ist es schon abgelaufen. In der Praxis kommen iss (issuer, Aussteller) und aud (audience, Empfänger) dazu.

Fünf typische Fälle für das Token aus Schritt 3 (jetzt = 1700000000), und wo sie scheitern:

Fall Was der Angreifer oder Zufall tut Scheitert in Schritt
Original nichts gültig, Payload wird zurückgegeben
Payload manipuliert "rolle": "admin" einsetzen, alte Signatur behalten 3 (Signatur passt nicht mehr zum Text)
alg: none Header auf none setzen, Signatur weglassen 2 (nicht in der Allowlist)
Abgelaufen Token von gestern wird wiederverwendet 4 (exp liegt in der Vergangenheit)
Müll abc oder kaputtes base64 1 (Form)

Warum ist alg: none gefährlich? Der Standard kennt einen Algorithmus none (“ungesichert”). Eine Bibliothek, die den Algorithmus aus dem Header übernimmt, prüft dann nichts: Der Angreifer schreibt sich ein beliebiges Token mit none und beliebigem Payload. Das ist ein Klassiker. Die Lehre: Vertraue nie einem Feld im Token, um zu entscheiden, wie du dem Token vertraust.

Warum konstantzeitig vergleichen? Ein normaler == auf Strings bricht beim ersten abweichenden Zeichen ab. Wer die Antwortzeit sehr genau misst, kann theoretisch Zeichen für Zeichen die richtige Signatur erraten (Timing-Angriff). hmac.compare_digest braucht unabhängig vom Inhalt gleich lang. Praktisch ist das schwer auszunutzen, aber es kostet nichts, also tust du es immer.

Eine Kleinigkeit zu HS256 gegen RS256: Bei HS256 (symmetrisch, ein gemeinsames Secret) kann jeder, der prüfen kann, auch Tokens ausstellen. Bei RS256 oder ES256 (asymmetrisch) signiert der Aussteller mit einem privaten Schlüssel, und alle anderen Services prüfen nur mit dem öffentlichen Schlüssel. Ein gestohlener Prüf-Service kann dann keine Tokens fälschen. Darum nutzen verteilte Systeme meist asymmetrische Signaturen.

Schritt 5: OAuth2 und OIDC, dann Authorization Code mit PKCE

OAuth2 regelt, wie eine App mit Erlaubnis des Nutzers auf eine Ressource zugreifen darf, ohne sein Passwort zu kennen. Vier Rollen: Resource Owner (der Nutzer), Client (die App), Authorization Server (der Identity Provider, wo der Login stattfindet) und Resource Server (die API). Ergebnis ist ein Access Token mit begrenzter Gültigkeit.

OIDC (OpenID Connect) setzt auf OAuth2 auf und beantwortet zusätzlich “wer ist der Nutzer?”: Es liefert ein ID Token (ein JWT mit sub, iss, aud, exp). OAuth2 allein ist Autorisierung, OIDC ergänzt die Authentifizierung.

Der empfohlene Ablauf heißt Authorization Code Flow mit PKCE:

sequenceDiagram
    participant U as Nutzer im Browser
    participant A as Client App
    participant S as Authorization Server
    A->>A: erzeugt code_verifier (geheim) und code_challenge (Hash davon)
    A->>S: Weiterleitung mit code_challenge
    U->>S: Login und Zustimmung
    S->>A: Weiterleitung mit authorization code (kurzlebig, einmalig)
    A->>S: Token-Anfrage mit code UND code_verifier
    S->>S: hasht den verifier und vergleicht mit der gespeicherten challenge
    S->>A: Access Token und Refresh Token

Der Trick: Der Code kommt über den Browser zurück (Front Channel), und dort kann ihn jemand mitlesen (bösartige App auf dem Gerät, Browser-Erweiterung, Logs). Den code_verifier schickt die App dagegen nur direkt an den Server (Back Channel), er steht nie in einer URL.

PKCE in drei Zeilen:

  1. code_verifier: zufälliger String, 43 bis 128 Zeichen aus A-Z a-z 0-9 - . _ ~. Er bleibt in der App.
  2. code_challenge = base64url(SHA256(code_verifier)), ohne Padding (Methode S256). Sie geht in der ersten Weiterleitung mit.
  3. Beim Einlösen rechnet der Server base64url(SHA256(mitgeschickter verifier)) und vergleicht mit der gespeicherten Challenge.

Durchgerechnet mit einem Verifier (aus echtem Python):

Ausgabe:

47 32 h6_dhHUD2WZcGO6SZihiTs4pvulj7utNp3wQP6s0zjY 43
mit Padding: h6_dhHUD2WZcGO6SZihiTs4pvulj7utNp3wQP6s0zjY=
Angreifer passt zur Challenge: False

Lies das so: 47 Zeichen Verifier, 32 Byte Hash, 43 Zeichen base64url. Mit Standard-Padding käme ein = am Ende dazu, das gehört nicht zu PKCE. Die Challenge ist ein Hash (Einwegfunktion), man kann den Verifier nicht zurückrechnen. Bei einem zufälligen Verifier mit genug Entropie (Zufälligkeit) kann man ihn auch nicht erraten. Deshalb ist S256 sicherer als die Methode plain (Challenge gleich Verifier): Wer die erste Weiterleitung mitliest, kennt bei plain schon das Geheimnis.

Wer scheitert wann? Ein Angreifer fängt den Code ab:

Ohne PKCE (öffentlicher Client, kein Client Secret) Mit PKCE
Angreifer löst Code am Token-Endpunkt ein klappt, der Server kann ihn nicht vom echten Client unterscheiden scheitert im Token-Endpunkt: Er hat keinen Verifier, und ein geratener passt nicht zur Challenge
Echter Client löst Code ein klappt (falls er schneller war) klappt, er kennt den Verifier

Und der Implicit Flow, die frühere Variante für Single-Page-Apps? Dort liefert der Authorization Server das Access Token selbst direkt in der Weiterleitungs-URL zurück, ohne Code und ohne Back Channel. Das Token landet damit im Browserverlauf, jede bösartige Erweiterung oder App, die die Adresse mitliest, kommt an das fertige Token, und es gibt keinen Beweis, dass der richtige Client es bekommt. Mit Code plus PKCE steht in der URL nur ein einmaliger Code, der ohne Verifier wertlos ist. Darum ist der Implicit Flow heute nicht mehr empfohlen (laut OAuth 2.0 Security Best Current Practice, Stand bitte prüfen). Das beantwortet den Prüfstein.

Schritt 6: Passwörter speichern

Du speicherst nie das Passwort, sondern einen Hash. Aber nicht irgendeinen:

Ausgabe:

99ca9d959b395b4cee37275607a2feaa195887d3642c808c394cf916f0e5d2c2
gleich bei zweitem Nutzer: True
934ec7d5314afe86ee3ffcb746558c517cba1d5c76193a003d35db2d18acd3b9
40b9071739a0ff26f9cee5e16937475564a13e9d4a8f0480488cf6c67c3a0108
9fad8646b8608947a51617b7c0861c9bfec340112bf73cb3d70958c1d89d09a2
pbkdf2_sha256$1000$000102030405060708090a0b0c0d0e0f$934ec7d5314afe86ee3ffcb746558c517cba1d5c76193a003d35db2d18acd3b9

Drei Probleme eines einfachen sha256(passwort), jeweils mit ihrem Gegenmittel:

  1. Gleiches Passwort, gleicher Hash (Zeile 1 und 2). Wer die Datenbank liest, sieht sofort, wer dasselbe Passwort hat, und kann fertige Tabellen (Rainbow Tables) benutzen. Gegenmittel: Salt, ein zufälliger Wert pro Nutzer (Zeile 3 und 4: gleiches Passwort, anderer Salt, ganz anderer Hash). Der Salt ist nicht geheim, er wird neben dem Hash gespeichert.
  2. Zu schnell. SHA-256 ist für Geschwindigkeit gebaut. Ein Angreifer mit gestohlener Datenbank probiert Milliarden Kandidaten. Gegenmittel: ein absichtlich langsamer Hash. PBKDF2 wiederholt die Berechnung iterationen mal (Zeile 5: schon eine Iteration mehr ergibt einen völlig anderen Wert). Mit 1000 Iterationen kostet jeder Rateversuch den Angreifer das 1000-Fache. Die Iterationszahl muss mit gespeichert werden, damit man sie später erhöhen kann.
  3. Format: Die letzte Zeile zeigt das übliche Speicherformat verfahren$iterationen$salt$hash. Beim Prüfen liest du Iterationen und Salt aus dem gespeicherten String, rechnest neu und vergleichst konstantzeitig.

Hinweis zur Praxis: hashlib.pbkdf2_hmac gibt exakt denselben Wert wie die Hilfsfunktion oben (lokal verifiziert), ist aber im Browser nicht verfügbar. In echten Projekten nimmst du Argon2id oder bcrypt aus einer geprüften Bibliothek. Sie sind zusätzlich speicherhungrig (Argon2) oder anders gebaut, sodass auch Grafikkarten-Rechenfarmen kaum Vorteile haben. Konkrete Parameter (Iterationen, Speicher) richtest du nach aktuellen Empfehlungen aus (z. B. OWASP, bitte prüfen), nicht nach diesem Browser-Beispiel.

Schritt 7: Token-Lebenszyklus, Refresh-Token-Rotation

Ein Access Token ist kurzlebig (Minuten), damit ein gestohlenes Token bald wertlos ist. Damit der Nutzer sich nicht ständig neu einloggt, gibt es zusätzlich ein Refresh Token: länger gültig, wird nur am Token-Endpunkt eingelöst und liefert ein neues Access Token. Das Refresh Token ist damit das wertvollste Ziel.

Gegen Diebstahl hilft Refresh-Token-Rotation: Jedes Refresh Token ist einmalig. Beim Einlösen bekommst du ein neues Refresh Token, das alte ist verbraucht. Und die Pointe, die Reuse Detection: Taucht ein bereits verbrauchtes Token wieder auf, hat es jemand kopiert. Der Server weiß nicht, wer der Dieb ist, also sperrt er die ganze Kette (Token Family), und der Nutzer muss sich neu anmelden.

Das Grundmuster “einmal benutzbar” kennst du aus dem Authorization Code (einmalig einlösbar). In Python:

Ausgabe:

anna None None

Beim Refresh-Token kommt zur Einmaligkeit die Familie dazu. Ein durchgerechneter Ablauf (Anna meldet sich an, ein Dieb besitzt eine Kopie von rt2):

Schritt Wer Aktion Ergebnis Zustand danach
1 Anna Login rt1 rt1 offen
2 Anna Refresh mit rt1 rt2 rt1 verbraucht, rt2 offen
3 Anna Refresh mit rt2 rt3 rt2 verbraucht, rt3 offen
4 Dieb Refresh mit rt2 (alt, kopiert) abgelehnt, Reuse erkannt ganze Familie gesperrt
5 Anna Refresh mit rt3 abgelehnt sie muss sich neu anmelden

Schritt 5 wirkt hart: Anna ist unschuldig und fliegt trotzdem raus. Das ist der Handel. Der Server kann Annas Gerät und den Dieb nicht unterscheiden, und die sichere Seite ist, beide auszusperren. Spielt der Dieb zuerst (er löst rt3 ein, bevor Anna es tut), trifft es genau andersherum: Annas späterer Versuch mit rt3 löst den Alarm aus und sperrt auch das neue Token des Diebs.

Schritt 8: Kurzüberblick ohne Übung

Drei Themen aus der Quelle, die du kennen musst, aber hier nicht selbst baust:

Thema Kernaussage
Symmetrisch vs. asymmetrisch Symmetrisch: ein Schlüssel zum Ver- und Entschlüsseln bzw. Signieren (schnell, Schlüsselaustausch ist das Problem). Asymmetrisch: Schlüsselpaar, privat zum Signieren oder Entschlüsseln, öffentlich zum Prüfen oder Verschlüsseln.
AEAD (authenticated encryption with associated data) Verschlüsselung, die Manipulation bemerkt (z. B. AES-GCM, ChaCha20-Poly1305). Verschlüsseln ohne Integritätsprüfung ist ein Fehler.
Key Management und Secrets Schlüssel und Secrets gehören nicht ins Repository, sondern in einen Secret Store oder ein KMS (Key Management Service), mit Rotation. Envelope Encryption: Daten mit einem Datenschlüssel verschlüsseln, den Datenschlüssel mit einem Hauptschlüssel. Least Privilege: jeder Dienst bekommt nur die Rechte, die er braucht.

Falle

  1. Dem Token vertrauen, wie es behauptet. alg aus dem Header übernehmen, exp nicht prüfen, den Payload auswerten, bevor die Signatur geprüft ist.
  2. JWT als Datenspeicher für Geheimnisse. Der Payload ist lesbar, nicht verschlüsselt.
  3. Langlebige JWTs ohne Widerruf. Ein 24-Stunden-Token lässt sich nicht sperren. Kurze Lebensdauer plus Refresh-Token, oder gleich Sessions.
  4. Passwörter mit schnellem Hash oder ohne Salt speichern. Dazu gehört der Irrtum, “SHA-256 ist doch sicher”. Der Hash ist sicher, aber für Passwörter viel zu schnell.
  5. Implicit Flow oder Code ohne PKCE in öffentlichen Clients (Single-Page-Apps, Mobile Apps).
  6. Kryptografie selbst bauen. Diese Lektion baut Teile nach, um sie zu verstehen. Im Projekt nutzt du geprüfte Bibliotheken für JWT, Passwort-Hashing und Verschlüsselung. Eigene Konstruktionen haben fast immer Lücken, die du erst bemerkst, wenn es zu spät ist.
  7. Reuse Detection ohne Bedacht auf Parallelität. Zwei Tabs, die gleichzeitig dasselbe Refresh Token einlösen, sehen aus wie ein Diebstahl. Produktionssysteme lassen darum oft eine kurze Schonfrist zu (allgemeines Fachwissen).

Übungen

Übung 1: JWT selbst prüfen (ca. 10 Min.)

Schreibe verifiere(token, secret, jetzt). Die Funktion gibt das Payload als dict zurück, wenn das Token gültig ist, sonst None. Sie darf nie eine Exception werfen, auch nicht bei Müll als Eingabe. Regeln:

  • Genau drei Teile, Header und Payload sind JSON.
  • Akzeptiert wird ausschließlich HS256 (Allowlist, der Server entscheidet).
  • Signatur über kopf.inhalt mit secret (bytes) neu berechnen und mit hmac.compare_digest vergleichen.
  • Ein Token ohne exp ist ungültig. Es ist gültig, solange jetzt < exp (jetzt in Unix-Sekunden).

Vorhanden sind b64url(bytes) und b64url_zurueck(text) aus dem Text.

Packe alles, was bei kaputten Eingaben schiefgehen kann (Aufteilen, Dekodieren, JSON), in einen gemeinsamen Schutz. Und überlege, in welcher Reihenfolge du Algorithmus, Signatur und Ablauf prüfst: Welchem Teil darfst du erst nach der Signaturprüfung trauen?

import hashlib, hmac, json

def verifiere(token, secret, jetzt):
    try:
        kopf, inhalt, signatur = token.split(".")
        header = json.loads(b64url_zurueck(kopf))
        if header.get("alg") != "HS256":
            return None
        erwartet = hmac.new(secret, f"{kopf}.{inhalt}".encode(), hashlib.sha256).digest()
        if not hmac.compare_digest(erwartet, b64url_zurueck(signatur)):
            return None
        payload = json.loads(b64url_zurueck(inhalt))
        if "exp" not in payload or not jetzt < payload["exp"]:
            return None
        return payload
    except Exception:
        return None

verifiere

Die Reihenfolge ist Form, Algorithmus aus der Allowlist, Signatur, erst dann exp. Der Algorithmus wird mit dem Wert im Server-Code verglichen, nicht aus dem Token übernommen. compare_digest verhindert Timing-Angriffe, und das try/except fängt kaputte Eingaben ab.

Übung 2: PKCE selbst bauen (ca. 8 Min.)

Schreibe drei Funktionen:

  • neuer_verifier(): ein kryptografisch sicherer code_verifier mit secrets (nicht random), 43 bis 128 Zeichen, nur aus A-Z a-z 0-9 - . _ ~.
  • challenge_s256(verifier): base64url(SHA256(verifier)) ohne =.
  • token_endpunkt(gespeicherte_challenge, verifier): gibt True zurück, wenn der Code mit diesem Verifier eingelöst werden darf, sonst False. Fehlt der Verifier (None oder leer), ist es False. Vergleiche konstantzeitig.

secrets hat eine Funktion, die schon URL-taugliche Zeichen liefert. Beim Hash: Du brauchst die rohen Bytes, nicht den Hex-String. Und was macht urlsafe_b64encode am Ende, das PKCE nicht will?

import base64, hashlib, hmac, secrets

def neuer_verifier():
    return secrets.token_urlsafe(32)

def challenge_s256(verifier):
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()

def token_endpunkt(gespeicherte_challenge, verifier):
    if not verifier:
        return False
    return hmac.compare_digest(challenge_s256(verifier), gespeicherte_challenge)

neuer_verifier, challenge_s256, token_endpunkt

secrets.token_urlsafe(32) liefert 43 Zeichen aus A-Z a-z 0-9 - _. Die Challenge ist der Hash in base64url ohne Padding. Der Token-Endpunkt rechnet selbst nach und lässt Anfragen ohne Verifier gar nicht erst durch.

Übung 3: Passwort speichern und prüfen (ca. 8 Min.)

Schreibe hash_passwort(passwort, iterationen=1000, salt=None) und pruefe_passwort(passwort, gespeichert).

  • hash_passwort gibt pbkdf2_sha256$<iterationen>$<salt_hex>$<hash_hex> zurück. Ohne salt (bytes) erzeugst du 16 zufällige Byte mit secrets. Das Passwort ist ein str und wird als UTF-8 kodiert.
  • pruefe_passwort liest Iterationen und Salt aus dem gespeicherten String, rechnet neu und vergleicht mit hmac.compare_digest. Für kaputte oder fremde Formate gibt sie False zurück, ohne Exception.
  • Die Hilfsfunktion pbkdf2(passwort_bytes, salt_bytes, iterationen) gibt 32 Byte zurück (aus dem Text, hier vorhanden).

Beim Prüfen darfst du dich auf nichts verlassen, was im Code steht und sich später ändern kann: Woher kommen die Parameter für die Neuberechnung? Und was passiert beim Zerlegen eines kaputten Strings?

import hmac, secrets

def hash_passwort(passwort, iterationen=1000, salt=None):
    if salt is None:
        salt = secrets.token_bytes(16)
    h = pbkdf2(passwort.encode("utf-8"), salt, iterationen)
    return f"pbkdf2_sha256${iterationen}${salt.hex()}${h.hex()}"

def pruefe_passwort(passwort, gespeichert):
    try:
        verfahren, iterationen, salt_hex, hash_hex = gespeichert.split("$")
        if verfahren != "pbkdf2_sha256":
            return False
        neu = pbkdf2(passwort.encode("utf-8"), bytes.fromhex(salt_hex), int(iterationen))
        return hmac.compare_digest(neu, bytes.fromhex(hash_hex))
    except Exception:
        return False

hash_passwort, pruefe_passwort

Der Salt ist pro Nutzer zufällig und wird mit gespeichert. Die Iterationen stehen im String, damit alte Hashes auch nach einer Erhöhung der Standardzahl prüfbar bleiben. compare_digest vergleicht in konstanter Zeit, das try/except macht kaputte Datensätze zu einem harmlosen False.

Übung 4: Refresh-Token-Rotation mit Reuse Detection (ca. 8 Min.)

Ergänze refresh(self, token). Vorgegeben sind der Speicher self.tokens (Token zu Eintrag mit familie und benutzt), die Menge self.gesperrt (gesperrte Familien) und _ausstellen(familie), das ein neues Token erzeugt. Regeln:

  • Unbekanntes Token: None.
  • Token einer gesperrten Familie: None.
  • Token ist schon benutzt (Reuse): die ganze Familie sperren und None zurückgeben.
  • Sonst: Token als benutzt markieren und ein neues Token derselben Familie zurückgeben.

Die Reihenfolge der Fragen zählt: Erst klären, ob das Token überhaupt existiert und seine Familie noch lebt, dann, ob es schon benutzt wurde. Und überlege, was nach einem Reuse mit den noch unbenutzten Tokens derselben Familie passieren soll.

class AuthServer:
    def __init__(self):
        self.tokens = {}
        self.gesperrt = set()
        self.n = 0

    def _ausstellen(self, familie):
        self.n += 1
        token = f"rt{self.n}"
        self.tokens[token] = {"familie": familie, "benutzt": False}
        return token

    def login(self, user):
        return self._ausstellen("familie_" + user)

    def refresh(self, token):
        eintrag = self.tokens.get(token)
        if eintrag is None:
            return None
        familie = eintrag["familie"]
        if familie in self.gesperrt:
            return None
        if eintrag["benutzt"]:
            self.gesperrt.add(familie)
            return None
        eintrag["benutzt"] = True
        return self._ausstellen(familie)

AuthServer

Reuse sperrt die Familie, und weil jeder Aufruf zuerst self.gesperrt prüft, sind danach auch die noch unbenutzten Tokens der Familie wertlos. Andere Familien sind nicht betroffen.

Übung 5: Sessions oder Tokens, und wer scheitert bei PKCE (ca. 6 Min.)

Zwei Fälle, je mit genau einer besten Antwort aus den genannten Randbedingungen. Gib ein Tupel (a, b) mit zwei Buchstaben zurück.

Fall 1. Eine interne Verwaltungsanwendung: ein einziges Backend mit einer gemeinsamen Datenbank, 150 Mitarbeitende, nur Browser, keine Drittanbieter oder Mobile Apps. Scheidet jemand aus, muss sein Zugang binnen Sekunden gesperrt sein. Welche Lösung passt?

  • A: JWT mit 24 Stunden Gültigkeit im Cookie, das der Server nur auf die Signatur prüft, ohne Sperrliste.
  • B: Zufällige Session-ID im HttpOnly-Cookie, Zustand in der Datenbank, Sperren durch Löschen des Eintrags.
  • C: JWT mit 15 Minuten Gültigkeit und Refresh Token, das der Server nur auf die Signatur prüft, ohne Sperrliste.
  • D: API-Schlüssel pro Mitarbeiter, einmal vergeben, im Browser gespeichert, ohne Ablauf oder Rotation.

Fall 2. Eine Single-Page-App (öffentlicher Client, kein Client Secret möglich) meldet Nutzer über einen Identity Provider an. Auf demselben Gerät liest eine bösartige App die Weiterleitungs-URL mit. Welche Aussage stimmt?

  • A: Der Implicit Flow genügt: Das Token steht nur im URL-Fragment und wird nie an einen Server gesendet.
  • B: Der Code ohne PKCE genügt: Ein Code ist nur einmal einlösbar, also bleibt die Mitleserin wirkungslos.
  • C: Mit PKCE ist der Code verschlüsselt: Nur die App besitzt den Schlüssel, um ihn zu lesen und einzulösen.
  • D: Mit PKCE fehlt der Mitleserin der Verifier, den der Token-Endpunkt gegen die Challenge prüft.

Fall 1: Welche Randbedingung lässt sich mit einem reinen Signaturcheck prinzipiell nicht erfüllen? Fall 2: Was reist durch die Weiterleitung, und was reist nur auf direktem Weg zum Server?

antwort = ("B", "D")
antwort

Fall 1: Sofortiges Sperren braucht serverseitigen Zustand, den man löschen kann, und ein einziges Backend mit gemeinsamer Datenbank hat damit kein Skalierungsproblem. Fall 2: Der Verifier reist nie über die Weiterleitung, der Token-Endpunkt rechnet beim Einlösen nach, und ohne den richtigen Verifier scheitert die Einlösung.

Merksatz

Vertraue einem Token erst, nachdem dein Server Algorithmus, Signatur und Ablauf geprüft hat, schütze Codes mit PKCE statt Implicit Flow, speichere Passwörter nur mit Salt und langsamem Hash, rotiere Refresh Tokens mit Reuse Detection, und baue Kryptografie nie selbst.

Prüfstein

  1. Warum Authorization Code mit PKCE statt Implicit Flow?
  2. Warum reicht Eingabevalidierung im Frontend nicht, und wo gehört Autorisierung hin?
  3. Ein Kollege schlägt vor, bei einem JWT einfach den alg aus dem Header zu verwenden. Was kann dann passieren?

Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “8. Security und Qualität” (Identität: Authentifizierung vs. Autorisierung, Sessions vs. Tokens, OAuth2/OIDC, JWT mit seinen Fallstricken; Kryptografie-Grundlagen: Hashing, symmetrisch vs. asymmetrisch, Signaturen; Hygiene: Secrets-Management, Least Privilege; Prüfstein zur Frontend-Validierung); quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 8 (Authentifizierung vertieft: OAuth2 Authorization Code mit PKCE, OIDC, Refresh-Token-Rotation, Token-Lebenszyklen; Kryptografie in der Praxis: Passwort-Hashing Argon2 und bcrypt, AEAD, Key Management, nichts selbst bauen; Prüfstein zu PKCE statt Implicit Flow).

Über die Quelle hinaus (allgemeines Fachwissen): Aufbau und Prüfreihenfolge eines JWT, alg: none, Timing-Angriffe, HS256 gegen RS256, Rollen und Ablauf von OAuth2 und PKCE (Verifier, Challenge, Methode S256, Länge 43 bis 128 Zeichen), die Schwächen des Implicit Flow (Stand der Empfehlung in OAuth 2.0 Security Best Current Practice und OAuth 2.1: bitte prüfen), Salt, Iterationen und Speicherformat beim Passwort-Hashing (konkrete Parameter für PBKDF2, Argon2 und bcrypt: bitte prüfen), die Funktionsweise der Reuse Detection und die Schonfrist bei Parallelität, Cookie-Flags HttpOnly, Secure, SameSite. Die Hashes, Token, Längen und Ausgaben stammen aus ausgeführtem Python (3.13 lokal), die PBKDF2-Hilfsfunktion wurde lokal gegen hashlib.pbkdf2_hmac und im Browser (Pyodide) auf Funktion geprüft, das Node-Beispiel mit Node 20 ausgeführt. In Pyodide fehlen hashlib.pbkdf2_hmac und hashlib.scrypt (im Browser getestet), darum die Hilfsfunktion.