Keycloak SSO Setup with Docker Compose in 30 Minutes [2025]

    Keycloak Single Sign-On setup actually works in 30 minutes. The problem is the 30 hours of debugging that follow the moment you move beyond localhost.

    What follows is basically the documentation Keycloak should have shipped with — working solutions for "redirect_uri errors" (haunting 476k+ developers on Stack Overflow), "CORS failures", and "cookie problems".

    Nothing fancy like geo-distributed clusters with HA Infinispan, just answers to the stuff that regularly goes wrong.

    All examples below are fully tested and working with:

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

    Quick Win: Setting up Keycloak with Docker Compose (10 minutes)

    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

    Access at: http://localhost:8080

    Why this works (prevents 2 common failures):

    • start-dev mode - Skips production validations
    • KC_HOSTNAME: http://localhost:8080 - Explicit hostname ensures redirect_uri matches

    Configure SSO Between Two Apps (The Part Nobody Explains)

    Step 1: Configure Keycloak — create realm and clients

    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

    Step 2: Create Node.js Apps with Single Sign-On

    The App 1.

    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'); });

    The App 2.

    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'); });

    Step 3: Install and Run

    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

    Test Your SSO Setup:

    • Go to http://localhost:3000/protected (App 1)
    • When Keycloak's login page appears, use user for both username and password
    • Click "Go to App 2"
    • You're now in App 2 without logging in again - that's SSO working!

    Your local development environment now includes:

    • Keycloak running in Docker Compose with persistent data
    • Single Sign-On working between two Node.js apps
    • CORS configured for apps (localhost:3000 and localhost:3001) → localhost:8080
    • Redirect URIs handling login/logout flows correctly
    • Session cookies working across domains
    • JWT tokens validated and rotating on demand

    Authentication is solved - focus on business logic.

    Realistic Timeline for Production Keycloak

    • Week 1: Basic SSO working locally (you are here ✓)
    • Week 2-3: Docker deployment, environment configuration
    • Week 4-6: Production hardening, security review
    • Week 7-8: Load testing, monitoring setup
    • Week 9-12: Edge cases, debugging, documentation

    Troubleshooting: 7 Most Common Keycloak SSO Issues

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

    The problem: Your redirect_uri looks perfect but Keycloak rejects it. You're not going crazy - this happens to everyone.

    The fix:

    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 command to verify:

    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"

    Expected result (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" Not Found

    The problem: SSO fails silently (Access denied, response code 403), each app requires separate login.

    The fix:

    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. Keycloak CORS Blocks Token Exchange

    The problem: Browser blocks Keycloak requests with CORS errors.

    The fix 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 Works Locally, Fails in Docker

    The problem: Everything works when Keycloak runs in Docker and your apps run locally via npm run dev. But the moment you add your apps to the same docker-compose, authentication breaks. Containers can't reach each other, localhost means different things inside Docker's network.

    The fix:

    Configure name resolution for host "keycloak.local", for example, on Linux add it to the /etc/hosts file.

    text
    127.0.0.1 localhost keycloak.local

    Docker compose with Keycloak and 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 Behind a Reverse Proxy (Session Affinity & Sticky Sessions)

    The problem: Users randomly lose authentication when load balanced.

    Docker compose: two Keycloak instances with shared DB behind 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:

    The fix (nginx.conf with 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 Expiration Breaks SSO

    The problem: SSO stops working after ~5 minutes (default token lifetime).

    Common misconception: this isn't a bug. Keycloak issues short-lived access tokens (5 min) and refresh tokens (longer validity). The client should automatically use the refresh token to get new access tokens, but often doesn't.

    For easier demonstration reduce Keycloak's default timeouts

    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

    Now the userinfo can only be fetched no more than 30 seconds after sign-in, then page need to be reloaded to refresh access token. But, in any case, after 120 seconds the session will be destroyed.

    The fix - Proper token refresh:

    The 'Quick Start' section above already includes this fix - here are the key aspects:

    Add code to handle token rotation. On the 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 }); });

    And on the client.

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

    Ensure tokens are rotated before making authenticated calls.

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

    7. Logout Doesn't Work Across Apps

    The problem: Logging out of one app doesn't log out of others.

    The fix - Implement Single Logout:

    Ensure the app set ups the logout endpoint using keycloak-connect's middleware

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

    Bonus: React Keycloak SSO (Quick Start)

    React is a Single Page Application, so it can run fully in the browser without a backend.
    In this case, you can’t use the standard Keycloak authorization code flow — instead, you need the PKCE flow.

    PKCE is used when the app can’t safely store a secret (like when all code runs in the browser), for example in mobile apps or single-page applications.

    Here’s a working configuration you can use as a starting point for your project.

    Keycloak setup for both scenarios:

    • Frontend-only SPA (React runs fully in the browser)
    • Frontend + Backend (API or server-side 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 App project setup:

    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" ] } }

    Web page:

    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> );

    Run the project:

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

    Bonus: When to Use Keycloak SSO vs Auth0/Okta

    Choose Keycloak SSO when

    • You need SSO across 3+ internal applications
    • Data sovereignty requirements (government, healthcare, finance)
    • Complex authentication flows (MFA, progressive profiling, conditional access)
    • Integrating with legacy systems (LDAP, Active Directory, SAML)
    • Budget conscious with 10,000+ users

    Choose Auth0/Okta when

    • You need SSO working this week, not next month
    • Less than 3 applications
    • Team smaller than 3 developers
    • No dedicated DevOps/infrastructure team
    • OK with US data jurisdiction

    Key Takeaways and Quick Fixes

    ErrorActual CauseFix
    "Invalid parameter: redirect_uri"URI doesn't match exactlyAdd wildcard: http://domain/*
    "Cookie not found"SameSite blockingSet to 'lax' or 'none'
    "Invalid token (wrong ISS)"Internal/external URL mismatchAlign server-url and auth-server-url
    "Session not active"Token expiredImplement token refresh
    "Failed to verify token"Clock skewSync server time with NTP
    "Unexpected error when processing authentication"Database connection lostAdd connection pooling

    This code works. It's the result of countless painful trial-and-error attempts by developers who've battled Keycloak and shared their hard-won solutions. To really get good at Keycloak, you need your own experience — but there's a better way to get it than breaking production.

    That's why we built AuthPractice. Real Keycloak instances you can break safely, right in your browser. No Docker, no setup — just hands-on learning before production breaks you.