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
Error | Actual Cause | Fix |
---|
"Invalid parameter: redirect_uri" | URI doesn't match exactly | Add wildcard: http://domain/* |
"Cookie not found" | SameSite blocking | Set to 'lax' or 'none' |
"Invalid token (wrong ISS)" | Internal/external URL mismatch | Align server-url and auth-server-url |
"Session not active" | Token expired | Implement token refresh |
"Failed to verify token" | Clock skew | Sync server time with NTP |
"Unexpected error when processing authentication" | Database connection lost | Add 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.