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"
Fehler | Tatsächliche Ursache | Lösung |
---|
"Invalid parameter: redirect_uri" | URI stimmt nicht exakt überein | Wildcard hinzufügen: http://domain/* |
"Cookie not found" | SameSite blockiert | Auf 'lax' oder 'none' setzen |
"Invalid token (wrong ISS)" | Interne/externe URL passt nicht | server-url und auth-server-url angleichen |
"Session not active" | Token abgelaufen | Token Refresh implementieren |
"Failed to verify token" | Uhren nicht synchron | Serverzeit mit NTP synchronisieren |
"Unexpected error when processing authentication" | Datenbankverbindung verloren | Connection 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.