Keycloak SSO Setup mit Docker Compose in 30 Minuten [2025]

    Keycloak Single Sign-On Setup funktioniert tatsächlich in 30 Minuten. Das Problem: die 30 Stunden Debugging, sobald man über die lokale Entwicklungsumgebung hinausgeht.

    Was hier steht, hätte eigentlich in der offiziellen Keycloak Doku stehen müssen — funktionierende Lösungen für "redirect_uri Fehler" (quälen über 476k Entwickler auf Stack Overflow), "CORS-Probleme" und "Cookie-Fehler".

    Kein ausgefallenes Setup mit geo-verteilten Clustern und HA Infinispan. Nur Lösungen für die Probleme, die regelmäßig auftreten.

    Alle folgenden Beispiele sind vollständig getestet und funktionieren mit:

    Keycloak 26.3, PostgreSQL 15, Docker 28.4, Node.js v24, React.js v19.

    Quick Win: Keycloak mit Docker Compose einrichten (10 Minuten)

    yaml
    # docker-compose.yml - Working Keycloak SSO setup name: keycloak-sso services: keycloak: image: quay.io/keycloak/keycloak:26.3 container_name: keycloak-sso environment: KC_DB: postgres KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak KC_DB_USERNAME: keycloak KC_DB_PASSWORD: password KC_HOSTNAME: http://localhost:8080 KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin command: start-dev ports: - "8080:8080" depends_on: - postgres postgres: image: postgres:15 container_name: postgres-sso environment: POSTGRES_DB: keycloak POSTGRES_USER: keycloak POSTGRES_PASSWORD: password volumes: - postgres_data:/var/lib/postgresql/data volumes: postgres_data:

    Start it: docker compose up -d

    Zugriff unter: http://localhost:8080

    Warum das funktioniert (verhindert 2 häufige Fehler):

    • start-dev Modus - Überspringt Produktions-Validierungen
    • KC_HOSTNAME: http://localhost:8080 - Expliziter Hostname stellt sicher, dass redirect_uri passt

    SSO zwischen zwei Apps konfigurieren (Der Teil, den niemand erklärt)

    Schritt 1: Keycloak konfigurieren — Realm und Clients erstellen

    bash
    # Quick setup via Admin CLI (faster than UI) docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh config credentials \ --server http://localhost:8080 \ --realm master \ --user admin \ --password admin # Create realm docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh create realms \ -s realm=authpractice \ -s enabled=true # Create first app client docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh create clients \ -r authpractice \ -s clientId=app1 \ -s enabled=true \ -s publicClient=false \ -s secret=app1-secret \ -s 'redirectUris=["http://localhost:3000/*"]' \ -s 'webOrigins=["http://localhost:3000"]' \ -s directAccessGrantsEnabled=true # Create second app client docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh create clients \ -r authpractice \ -s clientId=app2 \ -s enabled=true \ -s publicClient=false \ -s secret=app2-secret \ -s 'redirectUris=["http://localhost:3001/*"]' \ -s 'webOrigins=["http://localhost:3001"]' \ -s directAccessGrantsEnabled=true # Create a user in the `authpractice` realm docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh create users \ -r authpractice \ -s username=user \ -s enabled=true \ -s emailVerified=true \ -s email=user@example.com \ -s firstName=Test \ -s lastName=User # Set the user's password docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh set-password \ -r authpractice \ --username user \ --new-password user

    Schritt 2: Node.js-Apps mit Single Sign-On erstellen

    Die erste App:

    javascript
    // app1.js const express = require('express'); const session = require('express-session'); const Keycloak = require('keycloak-connect'); const app = express(); const memoryStore = new session.MemoryStore(); app.use(session({ secret: 'some-secret', resave: false, saveUninitialized: true, store: memoryStore, cookie: { httpOnly: true, secure: false, sameSite: 'lax' } })); const KEYCLOAK_URL = process.env.KEYCLOAK_URL || 'http://localhost:8080'; const keycloakConfig = { 'realm': 'authpractice', 'auth-server-url': KEYCLOAK_URL, 'ssl-required': 'none', 'resource': 'app1', 'credentials': { 'secret': 'app1-secret' }, 'confidential-port': 0, 'bearer-only': false }; const keycloak = new Keycloak({ store: memoryStore }, keycloakConfig); app.use(keycloak.middleware({ logout: '/logout', admin: '/' })); // Public route app.get('/', (req, res) => { res.send(` <h1>App 1</h1> <p><a href="/protected">Protected Page</a></p> <p><a href="http://localhost:3001/protected">Go to App 2 Protected</a></p> `); }); app.get('/api/get-token', keycloak.protect(), (req, res) => { res.json({ token: req.kauth.grant.access_token.token, expires: req.kauth.grant.access_token.content.exp }); }); // Protected route with CORS test app.get('/protected', keycloak.protect(), (req, res) => { const token = req.kauth.grant.access_token.token; const tokenExp = req.kauth.grant.access_token.content.exp; res.send(` <h1>App 1 - Protected</h1> <p>Logged in as: ${req.kauth.grant.access_token.content.preferred_username}</p> <p><a href="#" onclick="fetchUserInfo(); return false;">Fetch User Profile (CORS Test)</a></p> <script> let token = '${token}'; let tokenExp = ${tokenExp}; function getTimeLeft() { return tokenExp - Math.floor(Date.now() / 1000); } async function rotateToken() { const resp = await fetch('/api/get-token'); const data = await resp.json(); return { t: data.token, e: data.expires }; } async function fetchUserInfo() { const resultDiv = document.getElementById('result'); resultDiv.innerHTML = 'Loading...'; try { const { t, e } = await rotateToken(); token = t; tokenExp = e; const timeLeft = getTimeLeft(); resultDiv.innerHTML = '<h3>Token Status:</h3>' + '<p>Expires in: ' + timeLeft + ' seconds ' + (timeLeft > 0 ? '(valid)' : '(EXPIRED)') + '</p>' + '<p>Testing CORS...</p>'; const response = await fetch('${KEYCLOAK_URL}/realms/authpractice/protocol/openid-connect/userinfo', { headers: { 'Authorization': 'Bearer ' + token, 'Accept': 'application/json' } }); if (!response.ok) throw new Error('Request failed - token expired?'); const userInfo = await response.json(); resultDiv.innerHTML = '<h3>Token Status:</h3>' + '<p>Expires in: ' + timeLeft + ' seconds</p>' + '<h3>CORS Working! User Profile:</h3>' + '<pre>' + JSON.stringify(userInfo, null, 2) + '</pre>'; } catch (error) { const timeLeft = getTimeLeft(); resultDiv.innerHTML = '<h3>Token Status:</h3>' + '<p>Expires in: ' + timeLeft + ' seconds ' + (timeLeft > 0 ? '(should be valid)' : '(EXPIRED)') + '</p>' + '<h3>Error:</h3>' + '<p style="color:red">' + error.message + '</p>'; } } </script> <p><a href="http://localhost:3001/protected">Go to App 2 (should not require login)</a></p> <p><a href="/logout">Logout</a></p> <div id="result"></div> `); }); app.listen(3000, () => { console.log('App 1 running on http://localhost:3000'); });

    Die zweite App:

    javascript
    // app2.js const express = require('express'); const session = require('express-session'); const Keycloak = require('keycloak-connect'); const app = express(); const memoryStore = new session.MemoryStore(); app.use(session({ secret: 'some-secret', resave: false, saveUninitialized: true, store: memoryStore, cookie: { httpOnly: true, secure: false, sameSite: 'lax' } })); const KEYCLOAK_URL = process.env.KEYCLOAK_URL || 'http://localhost:8080'; const keycloakConfig = { 'realm': 'authpractice', 'auth-server-url': KEYCLOAK_URL, 'ssl-required': 'none', 'resource': 'app2', 'credentials': { 'secret': 'app2-secret' }, 'confidential-port': 0, 'bearer-only': false }; const keycloak = new Keycloak({ store: memoryStore }, keycloakConfig); app.use(keycloak.middleware({ logout: '/logout', admin: '/' })); // Public route app.get('/', (req, res) => { res.send(` <h1>App 2</h1> <p><a href="/protected">Protected Page</a></p> <p><a href="http://localhost:3000/protected">Go to App 1 Protected</a></p> `); }); app.get('/api/get-token', keycloak.protect(), (req, res) => { res.json({ token: req.kauth.grant.access_token.token, expires: req.kauth.grant.access_token.content.exp }); }); // Protected route with CORS test app.get('/protected', keycloak.protect(), (req, res) => { const token = req.kauth.grant.access_token.token; const tokenExp = req.kauth.grant.access_token.content.exp; res.send(` <h1>App 2 - Protected</h1> <p>Logged in as: ${req.kauth.grant.access_token.content.preferred_username}</p> <p><a href="#" onclick="fetchUserInfo(); return false;">Fetch User Profile (CORS Test)</a></p> <script> let token = '${token}'; let tokenExp = ${tokenExp}; function getTimeLeft() { return tokenExp - Math.floor(Date.now() / 1000); } async function rotateToken() { const resp = await fetch('/api/get-token'); const data = await resp.json(); return { t: data.token, e: data.expires }; } async function fetchUserInfo() { const resultDiv = document.getElementById('result'); resultDiv.innerHTML = 'Loading...'; try { const { t, e } = await rotateToken(); token = t; tokenExp = e; const timeLeft = getTimeLeft(); resultDiv.innerHTML = '<h3>Token Status:</h3>' + '<p>Expires in: ' + timeLeft + ' seconds ' + (timeLeft > 0 ? '(valid)' : '(EXPIRED)') + '</p>' + '<p>Testing CORS...</p>'; const response = await fetch('${KEYCLOAK_URL}/realms/authpractice/protocol/openid-connect/userinfo', { headers: { 'Authorization': 'Bearer ' + token, 'Accept': 'application/json' } }); if (!response.ok) throw new Error('Request failed - token expired?'); const userInfo = await response.json(); resultDiv.innerHTML = '<h3>Token Status:</h3>' + '<p>Expires in: ' + timeLeft + ' seconds</p>' + '<h3>CORS Working! User Profile:</h3>' + '<pre>' + JSON.stringify(userInfo, null, 2) + '</pre>'; } catch (error) { const timeLeft = getTimeLeft(); resultDiv.innerHTML = '<h3>Token Status:</h3>' + '<p>Expires in: ' + timeLeft + ' seconds ' + (timeLeft > 0 ? '(should be valid)' : '(EXPIRED)') + '</p>' + '<h3>Error:</h3>' + '<p style="color:red">' + error.message + '</p>'; } } </script> <p><a href="http://localhost:3000/protected">Go to App 1 (should not require login)</a></p> <p><a href="/logout">Logout</a></p> <div id="result"></div> `); }); app.listen(3001, () => { console.log('App 2 running on http://localhost:3001'); });

    Schritt 3: Installation und Start

    bash
    # Setup both apps npm init -y npm install express express-session keycloak-connect

    Start App 1.

    bash
    # In Terminal 1 node app1.js

    Start App 2.

    bash
    # In Terminal 2 node app1.js

    SSO Setup testen:

    • http://localhost:3000/protected aufrufen (App 1)
    • Wenn die Keycloak Login-Seite erscheint, user für Benutzername und Passwort verwenden
    • Auf "Go to App 2" klicken
    • Jetzt in App 2 ohne erneutes Login - das ist SSO!

    Die lokale Entwicklungsumgebung enthält jetzt:

    • Keycloak läuft in Docker Compose mit persistenten Daten
    • Single Sign-On funktioniert zwischen zwei Node.js Apps
    • CORS konfiguriert für Apps (localhost:3000 und localhost:3001) → localhost:8080
    • Redirect URIs verarbeiten Login/Logout-Flows korrekt
    • Session Cookies funktionieren domainübergreifend
    • JWT Tokens validiert und rotieren bei Bedarf

    Authentication ist gelöst - Fokus auf Business-Logik.

    Realistische Timeline für Keycloak SSO Projekt

    • Woche 1: Basic SSO läuft lokal
    • Woche 2-3: Docker Deployment, Umgebungskonfiguration
    • Woche 4-6: Production Hardening, Security Review
    • Woche 7-8: Load Testing, Monitoring Setup
    • Woche 9-12: Edge Cases, Debugging, Dokumentation

    Troubleshooting: 7 häufige Keycloak-SSO-Probleme

    1. "Invalid parameter: redirect_uri" (476k+ Stack Overflow Views)

    Das Problem: Die redirect_uri sieht perfekt aus, Keycloak lehnt sie trotzdem ab. Kennt jeder.

    Die Lösung:

    bash
    # These won't work -s 'redirectUris=["http://localhost:3000"]' # Missing /* -s 'redirectUris=["http://localhost:3000/callback"]' # Too specific -s 'redirectUris=["http://127.0.0.1:3000/*"]' # Wrong host # This will work -s 'redirectUris=["http://localhost:3000/*"]' # Wildcard required # For production with multiple environments -s 'redirectUris=[ "http://localhost:3000/*", "https://dev.example.com/*", "https://staging.example.com/*", "https://example.com/*" ]'

    Debug-Befehl zur Überprüfung:

    bash
    # Check exact redirect_uri being sent curl -s -D - -o /dev/null -G \ "http://localhost:8080/realms/authpractice/protocol/openid-connect/auth" \ --data-urlencode "client_id=app1" \ --data-urlencode "redirect_uri=http://localhost:3000/callback" \ --data-urlencode "response_type=code" \ --data-urlencode "scope=openid" \ --data-urlencode "state=test123" \ --data-urlencode "prompt=none"

    Erwartetes Ergebnis (Response Code 302):

    text
    HTTP/1.1 302 Found Set-Cookie: AUTH_SESSION_ID=XXX;Version=1;Path=/realms/authpractice/;Secure;HttpOnly;SameSite=None Set-Cookie: KC_AUTH_SESSION_HASH="YYY";Version=1;Path=/realms/authpractice/;Max-Age=60;Secure;SameSite=None Cache-Control: no-store, must-revalidate, max-age=0 Location: http://localhost:3000/callback?error=login_required&state=test123&iss=http%3A%2F%2Flocalhost%3A8080%2Frealms%2Fauthpractice Referrer-Policy: no-referrer Strict-Transport-Security: max-age=31536000; includeSubDomains X-Content-Type-Options: nosniff content-length: 0

    2. Cookie "KEYCLOAK_IDENTITY" nicht gefunden

    Das Problem: SSO versagt stillschweigend, jede App verlangt separates Login.

    Die Lösung:

    javascript
    // This breaks SSO cookie: { sameSite: 'strict', // Blocks cross-origin cookies secure: true, // Requires HTTPS domain: '.localhost' // Doesn't work as expected } // Use this for development cookie: { sameSite: 'lax', // Allows SSO redirects secure: false, // HTTP in dev httpOnly: true, maxAge: 1000 * 60 * 60 // 1 hour } // Use this for production cookie: { sameSite: 'none', // Required for cross-domain SSO secure: true, // Required with sameSite: none httpOnly: true, domain: '.yourdomain.com' // Shared parent domain }

    3. CORS blockiert Token-Austausch

    Das Problem: Browser blockiert Keycloak-Anfragen mit CORS-Fehlern.

    Die Lösung in Keycloak:

    App 1

    bash
    APP1_CLIENT_UUID="$(docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh get clients \ -r authpractice \ -q clientId=app1 \ --fields id \ --format csv \ --noquotes)" docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh update "clients/$APP1_CLIENT_UUID" \ -r authpractice \ -s 'webOrigins=["http://localhost:3000"]'

    App 2

    bash
    APP2_CLIENT_UUID="$(docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh get clients \ -r authpractice \ -q clientId=app2 \ --fields id \ --format csv \ --noquotes)" docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh update "clients/$APP2_CLIENT_UUID" \ -r authpractice \ -s 'webOrigins=["http://localhost:3001"]'

    4. Keycloak SSO funktioniert lokal, nicht in Docker

    Das Problem: Alles läuft, wenn Keycloak in Docker ist und die Apps lokal laufen (npm run dev). Sobald die Apps aber zur selben docker-compose hinzugefügt werden, funktioniert die Authentifizierung nicht mehr. Container erreichen sich gegenseitig nicht, denn localhost bedeutet innerhalb des Docker-Netzwerks etwas anderes.

    Die Lösung:

    Namensauflösung für "keycloak.local" einrichten, z.B. unter Linux in /etc/hosts eintragen.

    text
    127.0.0.1 localhost keycloak.local

    Docker Compose mit Keycloak und Apps.

    yaml
    # docker-compose.yml with proper networking name: keycloak-sso services: keycloak: image: quay.io/keycloak/keycloak:26.3 container_name: keycloak-sso environment: KC_DB: postgres KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak KC_DB_USERNAME: keycloak KC_DB_PASSWORD: password KC_HOSTNAME: http://keycloak.local:8080 # do not use 'localhost' hier KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin command: start-dev ports: - "8080:8080" depends_on: - postgres networks: - sso-network postgres: image: postgres:15 container_name: postgres-sso environment: POSTGRES_DB: keycloak POSTGRES_USER: keycloak POSTGRES_PASSWORD: password volumes: - postgres_data:/var/lib/postgresql/data networks: - sso-network app1: image: node:24-alpine container_name: app1-sso working_dir: /app command: sh -c "npm install && node app1.js" ports: - "3000:3000" volumes: - ./app1.js:/app/app1.js - ./package.json:/app/package.json - ./package-lock.json:/app/package-lock.json environment: KEYCLOAK_URL: http://keycloak.local:8080 depends_on: - keycloak networks: - sso-network extra_hosts: - "keycloak.local:host-gateway" # Maps to Docker host IP app2: image: node:24-alpine container_name: app2-sso working_dir: /app command: sh -c "npm install && node app2.js" ports: - "3001:3001" volumes: - ./app2.js:/app/app2.js - ./package.json:/app/package.json - ./package-lock.json:/app/package-lock.json environment: KEYCLOAK_URL: http://keycloak.local:8080 depends_on: - keycloak networks: - sso-network extra_hosts: - "keycloak.local:host-gateway" # Maps to Docker host IP volumes: postgres_data: networks: sso-network: driver: bridge

    5. Keycloak mit Reverse Proxy (Session-Bindung & Sticky Sessions)

    Das Problem: Benutzer fliegen bei Load Balancing zufällig raus.

    Docker Compose: zwei Keycloak-Server mit geteilter DB hinter Reverse Proxy.

    yaml
    # docker-compose.yml - Multi instance Keycloak setup with Nginx for fix demo purposes name: keycloak-sso services: keycloak1: image: quay.io/keycloak/keycloak:26.3 container_name: keycloak-sso environment: KC_DB: postgres KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak KC_DB_USERNAME: keycloak KC_DB_PASSWORD: password KC_HOSTNAME: http://localhost:8080 KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin command: start-dev depends_on: - postgres keycloak2: image: quay.io/keycloak/keycloak:26.3 container_name: keycloak-sso-2 environment: KC_DB: postgres KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak KC_DB_USERNAME: keycloak KC_DB_PASSWORD: password KC_HOSTNAME: http://localhost:8080 KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin entrypoint: ["/bin/sh", "-c"] command: ["sleep 30 && /opt/keycloak/bin/kc.sh start-dev"] # Please adjust this waiting time so the keycloak2 is started after keycloak1 ready depends_on: - keycloak1 postgres: image: postgres:15 container_name: postgres-sso environment: POSTGRES_DB: keycloak POSTGRES_USER: keycloak POSTGRES_PASSWORD: password volumes: - postgres_data:/var/lib/postgresql/data nginx: image: nginx:1.29.1-bookworm container_name: nginx-sso ports: - "8080:8080" volumes: - ./nginx.conf:/etc/nginx/nginx.conf depends_on: - keycloak1 - keycloak2 volumes: postgres_data:

    Die Lösung (nginx.conf mit Sticky Sessions):

    text
    # nginx.conf events { worker_connections 1024; } http { upstream keycloak { ip_hash; # Fix with sticky sessions based on IP server keycloak1:8080; server keycloak2:8080; } server { listen 8080; server_name localhost; location / { proxy_pass http://keycloak; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } }

    6. Token-Ablauf unterbricht SSO

    Das Problem: Nach etwa 5 Minuten ist Schluss mit SSO (Standard Token-Lebensdauer).

    Häufiger Irrtum: Das ist kein Fehler. Keycloak gibt kurzlebige Zugriffs-Token (5 Min) und Aktualisierungs-Token (längere Gültigkeit) aus. Der Client sollte automatisch das Refresh-Token verwenden, um neue Access-Token zu erhalten, macht das aber oft nicht.

    Für leichtere Demonstration: Keycloak-Standardzeitlimits verkürzen.

    bash
    # Sign-in docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh config credentials \ --server http://localhost:8080 \ --realm master \ --user admin \ --password admin # Print the defaults, in case you need to restore them after demonstration docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh get realms/authpractice \ --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan # Change the timeouts for smaller ones just for the demonstration purposes docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh update realms/authpractice \ -s accessTokenLifespan=30 \ -s ssoSessionIdleTimeout=60 \ -s ssoSessionMaxLifespan=120

    Jetzt kann die Userinfo nur noch bis 30 Sekunden nach Sign-in abgerufen werden, danach muss die Seite neu geladen werden, um den Access Token zu refreshen. Nach 120 Sekunden wird die Session aber in jedem Fall zerstört.

    Die Lösung - Korrekter Token-Refresh:

    Der 'Quick Start' oben enthält bereits diese Lösung - hier die wichtigsten Aspekte:

    Code für die Token-Rotation hinzufügen. Im Backend.

    javascript
    app.get('/api/get-token', keycloak.protect(), (req, res) => { res.json({ token: req.kauth.grant.access_token.token, expires: req.kauth.grant.access_token.content.exp }); });

    Und im Client.

    javascript
    async function rotateToken() { const resp = await fetch('/api/get-token'); const data = await resp.json(); return { t: data.token, e: data.expires }; }

    Sicherstellen, dass Tokens vor authentifizierten Aufrufen rotiert werden.

    javascript
    const { t, e } = await rotateToken(); token = t; tokenExp = e;

    7. Logout funktioniert nicht über Apps hinweg

    Das Problem: Logout aus einer App bedeutet nicht automatisch Logout aus allen.

    Die Lösung - Single Logout implementieren:

    javascript
    app.use(keycloak.middleware({ logout: '/logout', admin: '/' }));

    Bonus: React Keycloak SSO (Quick Start)

    React ist eine Single Page Application, daher kann sie komplett im Browser ohne Backend laufen.
    In diesem Fall kannst du nicht den normalen Keycloak Authorization Code Flow verwenden — stattdessen brauchst du den PKCE Flow.

    Der PKCE Flow wird genutzt, wenn die App kein Secret sicher speichern kann (zum Beispiel wenn der gesamte Code im Browser läuft), etwa bei mobilen Apps oder Single Page Applications.

    Hier ist eine funktionierende Konfiguration als Startpunkt für das Projekt.

    Keycloak-Setup für beide Szenarien:

    • Nur Frontend (SPA) – React läuft komplett im Browser
    • Frontend + Backend – API oder serverseitige App
    bash
    # Create react app client docker exec keycloak-sso /opt/keycloak/bin/kcadm.sh create clients \ --server http://localhost:8080 \ --realm master \ --user admin \ --password admin \ -r authpractice \ -s clientId=react-app \ -s enabled=true \ -s publicClient=true \ -s 'redirectUris=["http://localhost:3000/*"]' \ -s 'webOrigins=["http://localhost:3000"]' \ -s standardFlowEnabled=true

    React-Projekt einrichten:

    json
    { "name": "keycloak-react-sso", "version": "0.1.0", "private": true, "dependencies": { "@react-keycloak/web": "^3.4.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", "keycloak-js": "^26.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.1", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, "eslintConfig": { "extends": [ "react-app", "react-app/jest" ] }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } }

    Webseite:

    html
    <!-- public/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>React App with Keycloak</title> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> </body> </html>

    React-App Code:

    javascript
    // src/index.js import React, { useEffect, useState } from "react"; import ReactDOM from "react-dom/client"; import { ReactKeycloakProvider, useKeycloak } from "@react-keycloak/web"; import Keycloak from "keycloak-js"; import { BrowserRouter, Routes, Route, Link } from "react-router-dom"; // --- Step 1: Initialize Keycloak instance --- const keycloak = new Keycloak({ url: "http://localhost:8080", realm: "authpractice", clientId: "react-app", }); // --- Step 2: PrivateRoute component --- // This component ensures that only authenticated users can access the route. // If not authenticated, it will initiate the login process. function PrivateRoute({ children }) { const { keycloak, initialized } = useKeycloak(); useEffect(() => { if (initialized && !keycloak.authenticated) { keycloak.login(); } }, [initialized, keycloak, keycloak.authenticated]); if (!initialized) { return <div>Loading...</div>; } return keycloak.authenticated ? children : <div>Redirecting to login...</div>; } // --- Step 3: Page Components --- function HomePage() { return ( <div> <h1>React App</h1> <p> <Link to="/protected" style={{ marginRight: "10px" }}> Protected Page </Link> </p> </div> ); } function ProtectedPage() { const { keycloak } = useKeycloak(); const [userInfo, setUserInfo] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [tokenStatus, setTokenStatus] = useState(""); const getTimeLeft = () => { if (!keycloak.tokenParsed?.exp) return 0; return keycloak.tokenParsed.exp - Math.floor(Date.now() / 1000); }; const fetchUserInfo = async () => { setLoading(true); setError(null); setUserInfo(null); try { // Refresh the token to ensure it's valid await keycloak.updateToken(5); const timeLeft = getTimeLeft(); setTokenStatus( `Expires in: ${timeLeft} seconds ${ timeLeft > 0 ? "(valid)" : "(EXPIRED)" }` ); const response = await fetch( `${keycloak.authServerUrl}/realms/${keycloak.realm}/protocol/openid-connect/userinfo`, { headers: { Authorization: `Bearer ${keycloak.token}`, Accept: "application/json", }, } ); if (!response.ok) { throw new Error( "Failed to fetch user info. Is CORS configured correctly in Keycloak?" ); } const data = await response.json(); setUserInfo(data); } catch (err) { const timeLeft = getTimeLeft(); setTokenStatus( `Expires in: ${timeLeft} seconds ${ timeLeft > 0 ? "(should be valid)" : "(EXPIRED)" }` ); setError(err.message); } finally { setLoading(false); } }; return ( <div> <h1>React App - Protected</h1> <p>Logged in as: {keycloak.tokenParsed?.preferred_username}</p> <p> <a href="/#" onClick={(e) => { e.preventDefault(); fetchUserInfo(); }} > Fetch User Profile (CORS Test) </a> </p> {loading && <p>Loading...</p>} <div id="result"> <p> <a href="#" onClick={(e) => { e.preventDefault(); keycloak.logout({ redirectUri: window.location.origin }); }} > Logout </a> </p> {tokenStatus && <h3>Token Status:</h3>} {tokenStatus && <p>{tokenStatus}</p>} {error && ( <> <h3>Error:</h3> <p style={{ color: "red" }}>{error}</p> </> )} {userInfo && ( <> <h3>CORS Working! User Profile:</h3> <pre>{JSON.stringify(userInfo, null, 2)}</pre> </> )} </div> </div> ); } // --- Step 4: Define the main App component with routing --- function App() { const { initialized } = useKeycloak(); if (!initialized) { return <div>Loading...</div>; } return ( <BrowserRouter> <Routes> <Route path="/" element={<HomePage />} /> <Route path="/protected" element={ <PrivateRoute> <ProtectedPage /> </PrivateRoute> } /> </Routes> </BrowserRouter> ); } // --- Step 5: Render the application --- const root = ReactDOM.createRoot(document.getElementById("root")); root.render( <ReactKeycloakProvider authClient={keycloak} initOptions={{ onLoad: "check-sso", // Check for existing session, don't force login pkceMethod: "S256", }} > <React.StrictMode> <App /> </React.StrictMode> </ReactKeycloakProvider> );

    Projekt starten:

    bash
    # Install dependencies npm install # Start the application npm start

    Bonus: Wann Keycloak SSO vs Auth0/Okta verwenden

    Keycloak SSO wählen wenn

    • SSO für 3+ interne Anwendungen benötigt wird
    • Datensouveränitäts-Anforderungen bestehen (Behörden, Gesundheitswesen, Finanzen)
    • Komplexe Authentication Flows nötig sind (MFA, Progressive Profiling, Conditional Access)
    • Legacy-Systeme integriert werden müssen (LDAP, Active Directory, SAML)
    • Budget-bewusst bei 10.000+ Benutzern

    Auth0/Okta wählen wenn

    • SSO diese Woche laufen muss, nicht nächsten Monat
    • Weniger als 3 Anwendungen
    • Team kleiner als 3 Entwickler
    • Kein dediziertes DevOps/Infrastructure Team
    • US-Datenjurisdiktion kein Problem ist

    Key Takeaways und Quick Fixes"

    FehlerTatsächliche UrsacheLösung
    "Invalid parameter: redirect_uri"URI stimmt nicht exakt übereinWildcard hinzufügen: http://domain/*
    "Cookie not found"SameSite blockiertAuf 'lax' oder 'none' setzen
    "Invalid token (wrong ISS)"Interne/externe URL passt nichtserver-url und auth-server-url angleichen
    "Session not active"Token abgelaufenToken Refresh implementieren
    "Failed to verify token"Uhren nicht synchronServerzeit mit NTP synchronisieren
    "Unexpected error when processing authentication"Datenbankverbindung verlorenConnection Pooling einrichten

    Dieser Code funktioniert. Jede Zeile ist das Ergebnis unzähliger schmerzhafter Trial-and-Error Versuche von Entwicklern, die mit Keycloak gekämpft und ihre hart erkämpften Lösungen geteilt haben. Um wirklich gut mit Keycloak umgehen zu können, braucht es eigene praktische Erfahrung — aber die muss man nicht gleich in Production sammeln.

    Daher haben wir AuthPractice gebaut. Echte Keycloak-Instanzen zum sicheren Experimentieren, direkt im Browser. Kein Docker-Setup erforderlich — nur praktisches Lernen, bevor Production zum Problem wird.