How to Set Up Keycloak 26.8 for OIDC and SAML SSO: Step-by-Step (2026)
This guide is for engineers who need single sign-on in their own product and want to run the identity provider themselves rather than rent one. By the end you will have Keycloak 26.8.1 running in production mode on PostgreSQL 18 (Docker Compose, with the Kubernetes Operator variant alongside), a dedicated realm, an OIDC client for your app using the authorization code flow with PKCE, a SAML client for a SAML-only service provider, a customer's own IdP brokered into the realm, and the SCIM API switched on. Everything was checked on 31 October 2026 against the Keycloak 26.8.1 release (13 October 2026) and the current server guides. You need Docker with Compose v2 or a Kubernetes cluster, a DNS name you control, and a reverse proxy that can terminate TLS. If you are still deciding whether to self-host at all, the companion piece on what enterprise-ready SaaS needs in 2026 covers SSO, SCIM, audit logs and build versus buy.
How the pieces fit together
Keycloak is a broker in the middle. Your application only ever talks OIDC to Keycloak. Keycloak can then authenticate the user locally, or hand them off to a customer's Okta, Entra ID or Google Workspace over OIDC or SAML and map the result back into a local user. SAML-only service providers (old HR tools, some vendor portals) get a SAML client in the same realm. One realm per product environment is the usual pattern; one realm per customer only makes sense when customers need fully separate configuration.
Step 1: Run Keycloak 26.8.1 with PostgreSQL 18 on Docker Compose
Keycloak has two modes. start-dev uses insecure defaults and an embedded database, and the container guide says it "should be strictly avoided in production". start is production mode: it requires a hostname (or an explicit --hostname-strict=false), expects HTTPS, and refuses to guess. Build the setup in production mode from day one so you debug the real thing.
PostgreSQL 18.x is the version Keycloak tests against, with 14 through 17 also supported, per the database guide. This Compose file puts Caddy in front for TLS (edge termination), which is why KC_HTTP_ENABLED is on and Keycloak's port is not published to the internet.
# compose.yaml (Keycloak 26.8.1, PostgreSQL 18.6, Caddy 2.11.7)
services:
postgres:
image: postgres:18.6
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes:
- pgdata:/var/lib/postgresql # PG 18 images keep data under a versioned subdirectory here
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
interval: 5s
retries: 10
keycloak:
image: quay.io/keycloak/keycloak:26.8.1
command: ["start"] # production mode, never start-dev here
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${KC_DB_PASSWORD} # same value as secrets/db_password.txt
KC_HOSTNAME: https://auth.example.com # full URL: scheme, host, no trailing slash
KC_HTTP_ENABLED: "true" # required for edge TLS termination
KC_PROXY_HEADERS: xforwarded # Caddy sets X-Forwarded-*
KC_PROXY_TRUSTED_ADDRESSES: 172.16.0.0/12 # only trust headers from the Docker network
KC_HEALTH_ENABLED: "true" # /health/ready on management port 9000
KC_METRICS_ENABLED: "true"
KC_BOOTSTRAP_ADMIN_USERNAME: tmp-admin # temporary; replace after first login
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
depends_on:
postgres:
condition: service_healthy
mem_limit: 2g # heap is 70% of the container limit
ports:
- "127.0.0.1:9000:9000" # health and metrics, local only
caddy:
image: caddy:2.11.7
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
secrets:
db_password:
file: ./secrets/db_password.txt
volumes:
pgdata:
caddy_data:
# Caddyfile: Caddy gets a certificate automatically and overwrites X-Forwarded-* headers
auth.example.com {
reverse_proxy keycloak:8080
}
The 2 GB memory limit is the figure the container guide recommends for smaller production deployments; without a limit the JVM heap can grow to 70% of the host's memory. Bring it up and wait for readiness:
docker compose up -d
curl -s http://127.0.0.1:9000/health/ready
# {"status": "UP", "checks": [...]}
The bootstrap admin is meant to be temporary. Log in at https://auth.example.com/admin/, create a permanent admin user in the master realm with MFA, and delete tmp-admin. The bootstrap and recovery guide covers getting back in if you lock yourself out.
Step 2 (alternative): Deploy on Kubernetes with the Keycloak Operator
On Kubernetes, use the Operator rather than a hand-rolled Deployment. The installation guide pins the kustomization to the release tag:
kubectl create namespace keycloak
kubectl apply -k 'github.com/keycloak/keycloak-k8s-resources/kubernetes?ref=26.8.1'
kubectl -n keycloak create secret generic keycloak-db-secret \
--from-literal=username=keycloak --from-literal=password='use-a-real-secret'
kubectl -n keycloak create secret tls keycloak-tls --cert cert.pem --key key.pem
The Operator does not run your database. Point it at a managed PostgreSQL or a database operator such as CloudNativePG. The Keycloak resource uses API version k8s.keycloak.org/v2beta1 in the current basic deployment guide:
# keycloak.yaml
apiVersion: k8s.keycloak.org/v2beta1
kind: Keycloak
metadata:
name: auth
namespace: keycloak
spec:
instances: 2 # two pods so a node drain does not log everyone out
db:
vendor: postgres
host: keycloak-pg-rw # your Postgres service
usernameSecret: { name: keycloak-db-secret, key: username }
passwordSecret: { name: keycloak-db-secret, key: password }
http:
tlsSecret: keycloak-tls
hostname:
hostname: auth.example.com
proxy:
headers: xforwarded
kubectl apply -f keycloak.yaml
kubectl -n keycloak get keycloaks/auth -o go-template='{{range .status.conditions}}{{.type}}={{.status}} {{end}}'
# Ready=true HasErrors=false RollingUpdate=false
The Operator creates the bootstrap admin secret, named after the CR with an -initial-admin suffix (auth-initial-admin here). As with Compose, replace that admin early.
Step 3: Create a realm for your product
Do not put applications in master. It is the administrative realm, and the reverse proxy guide recommends keeping /realms/master/ off the public internet entirely. Use the admin CLI that ships in the image (it prompts for the password):
alias kcadm='docker compose exec keycloak /opt/keycloak/bin/kcadm.sh'
kcadm config credentials --server http://localhost:8080 --realm master --user admin # the permanent admin from Step 1
kcadm create realms -s realm=acme -s enabled=true \
-s sslRequired=external \
-s registrationAllowed=false \
-s bruteForceProtected=true
sslRequired=external lets private addresses use plain HTTP and forces HTTPS for everything else; all forces it everywhere, and none is for experiments only, per the server administration guide. Check the realm is live through the public hostname:
curl -s https://auth.example.com/realms/acme/.well-known/openid-configuration | jq -r .issuer
# https://auth.example.com/realms/acme
If the issuer shows http:// or an internal host, stop and fix the hostname and proxy settings now, because every token will carry that issuer.
Step 4: Add an OIDC client for your application
Create a confidential client using the authorization code flow, PKCE with S256, an exact redirect URI and no implicit or password grants:
kcadm create clients -r acme \
-s clientId=webapp \
-s enabled=true \
-s protocol=openid-connect \
-s publicClient=false \
-s standardFlowEnabled=true \
-s implicitFlowEnabled=false \
-s directAccessGrantsEnabled=false \
-s 'redirectUris=["https://app.example.com/auth/callback"]' \
-s 'webOrigins=["https://app.example.com"]' \
-s 'attributes."pkce.code.challenge.method"=S256' \
-s 'attributes."post.logout.redirect.uris"=https://app.example.com/' \
-i
# prints the internal client UUID, for example 3f0c...
CID=$(kcadm get clients -r acme -q clientId=webapp --fields id --format csv --noquotes)
kcadm get clients/$CID/client-secret -r acme
Avoid wildcard redirect URIs like https://app.example.com/* in production. Keycloak allows a trailing wildcard, but an exact match removes a whole class of open-redirect token theft. In your app, every mainstream OIDC library needs the same four values:
# .env for your app (any OIDC library: openid-client, Authlib, Spring Security, oauth2-proxy)
OIDC_ISSUER=https://auth.example.com/realms/acme
OIDC_CLIENT_ID=webapp
OIDC_CLIENT_SECRET=<from the command above>
OIDC_REDIRECT_URI=https://app.example.com/auth/callback
OIDC_SCOPES="openid profile email"
The library reads the discovery document, sends the user to /realms/acme/protocol/openid-connect/auth, and exchanges the code at /token. Validate the ID token's iss and aud and you are done. Single-page apps with no backend should be public clients (publicClient=true) with PKCE, never a client secret shipped to the browser.
For machine-to-machine calls, including AI agents that call your API, create a separate client with serviceAccountsEnabled=true and standardFlowEnabled=false, and use the client credentials grant. Do not reuse the user-facing client. Keycloak 26.8 also added token exchange delegation with an act claim (preview) for agents acting on behalf of a user; the non-human identity post explains why that claim matters.
Step 5: Add a SAML client for a SAML-only service provider
For SAML, the client ID must equal the service provider's entity ID, because Keycloak matches it against the issuer in the AuthnRequest. Register the Assertion Consumer Service (ACS) URL explicitly:
kcadm create clients -r acme \
-s clientId=https://hr.example.net/saml/metadata \
-s protocol=saml \
-s enabled=true \
-s 'redirectUris=["https://hr.example.net/saml/acs"]' \
-s 'attributes."saml_assertion_consumer_url_post"=https://hr.example.net/saml/acs' \
-s 'attributes."saml.assertion.signature"=true' \
-s 'attributes."saml.server.signature"=true' \
-s 'attributes."saml.client.signature"=false' \
-s 'attributes."saml_name_id_format"=email' \
-s 'attributes."saml_force_name_id_format"=true'
The signature and NameID keys are defined in Keycloak's SamlConfigAttributes source, and the ACS key in SamlProtocol next to it; the admin console sets the same fields under the client's Settings and Keys tabs. Set saml.client.signature=true if the SP signs its requests and you have imported its certificate. Give the SP the realm's IdP metadata URL:
curl -s https://auth.example.com/realms/acme/protocol/saml/descriptor | head -c 300
# <md:EntityDescriptor ... entityID="https://auth.example.com/realms/acme">
Most SPs accept that URL directly and pull the signing certificate from it, which also means key rotation does not need a support ticket.
Step 6: Broker a customer's IdP and turn on SCIM
This is the step that makes you "enterprise ready": a customer's employees log in with their own Okta or Entra ID, and their directory pushes users to you. Add the customer's IdP as a generic OIDC provider (use providerId=saml with config.singleSignOnServiceUrl for SAML upstreams):
kcadm create identity-provider/instances -r acme \
-s alias=customer-globex \
-s providerId=oidc \
-s enabled=true \
-s 'config.useJwksUrl="true"' \
-s config.issuer=https://login.globex.example/oauth2/default \
-s config.authorizationUrl=https://login.globex.example/oauth2/default/v1/authorize \
-s config.tokenUrl=https://login.globex.example/oauth2/default/v1/token \
-s config.jwksUrl=https://login.globex.example/oauth2/default/v1/keys \
-s config.clientId=GLOBEX_CLIENT_ID \
-s config.clientSecret=GLOBEX_CLIENT_SECRET \
-s config.pkceEnabled=true \
-s config.pkceMethod=S256
The customer registers Keycloak as an app on their side with the redirect URI https://auth.example.com/realms/acme/broker/customer-globex/endpoint. Your app can skip the Keycloak login screen by adding kc_idp_hint=customer-globex to the authorization request, which is how you route users by email domain.
SCIM went from preview to supported in 26.8.0, and the scim-api feature is now on by default, but each realm must opt in. In the admin console, open Realm settings, General, switch on SCIM API, and save. The base URL is https://auth.example.com/realms/acme/scim/v2, with /Users, /Groups, /Schemas and /ServiceProviderConfig beneath it. Only confidential clients with a service account can call it, so create a scim-globex client with service accounts enabled, grant it the realm-management roles it needs (for example manage-users and view-users), turn Full Scope Allowed off, and add an Audience mapper whose value is the SCIM base URL, because the SCIM API rejects tokens without that aud with a 401. Then give the customer's directory the token endpoint and credentials. The SCIM chapter lists the role combinations.
Step 7: Production hardening checklist
Most Keycloak incidents are configuration, not code. Before real users arrive:
- Upgrade promptly. 26.8.1 alone fixed more than a dozen CVEs, including broker logout token forgery and fine-grained admin privilege escalation, per the release notes. Subscribe to the releases feed.
- Block
/admin/and/realms/master/at the proxy, or serve the console on a separate internal hostname with--hostname-admin. Settinghostname-adminalone does not block the admin API on the public name, as the hostname guide warns. - Run at least two instances. They cluster over JGroups and Infinispan by default, and 26.8 made the database-only
statelessmulti-cluster mode supported for teams that span regions. - Export realms to Git (
kc.sh export --dir ./export --realm acme, ideally with nodes stopped, or aKeycloakRealmImportresource) so configuration is reviewable, then stop clicking in production. - Ship login and admin events to your SIEM. Enterprise customers will ask for exactly that audit trail.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Login page says "Invalid parameter: redirect_uri" | The app's redirect_uri does not match any Valid Redirect URI (trailing slash, http vs https, port, different path) |
Copy the exact value from the browser URL into the client's redirectUris; check the app builds an https URL behind the proxy |
| "HTTPS required" on the login page | Realm sslRequired is external or all and Keycloak thinks the request is plain HTTP from a public IP |
Set KC_PROXY_HEADERS to match the proxy (xforwarded or forwarded) so Keycloak sees X-Forwarded-Proto: https; do not set the realm to none |
| 403 Forbidden on the admin console through the proxy | proxy-headers not set, so origin checks fail |
Set --proxy-headers xforwarded (or forwarded) and --proxy-trusted-addresses to the proxy's CIDR |
Token iss is http://keycloak:8080/realms/acme and the app rejects it |
Hostname not set to the public URL | Set KC_HOSTNAME=https://auth.example.com; recheck the discovery document |
| Keycloak exits at boot asking for a hostname | Production mode needs --hostname or --hostname-strict=false |
Set the hostname; avoid strict=false unless the proxy fully controls Host headers |
| Container is OOMKilled or slow under load | No memory limit, or a limit far below the heap the JVM chose | Set a container limit (2 GB is the documented small-production figure) or tune JAVA_OPTS_KC_HEAP |
SAML login fails with "Invalid Request" and a client_not_found event |
SP entity ID differs from the Keycloak client ID | Make the client ID exactly the SP's entityID, including scheme and trailing path |
| Brokered login ends at the customer IdP with a redirect error | Customer registered the wrong redirect URI | They must register /realms/acme/broker/ALIAS/endpoint on the public hostname |
| SCIM calls return 401 | Public client, missing realm toggle, token without the SCIM base URL as aud, or no realm-management roles |
Use a confidential service-account client, enable SCIM API on the realm, add an Audience mapper for /realms/acme/scim/v2, grant view-users/manage-users |
FAQ
Is Keycloak free for commercial use?
Yes. Keycloak is Apache-2.0 licensed and a CNCF incubating project, so you can run it inside a commercial SaaS without fees. You pay in operations instead: upgrades, database backups, high availability and on-call. Red Hat sells a supported build if you want a vendor to call.
Should I use start-dev or start in Docker?
Use start-dev only on your laptop. It runs with insecure defaults and the embedded dev-file database. Anything reachable by other people should use start with PostgreSQL and a real hostname, even staging, so that proxy and hostname bugs show up before production.
OIDC or SAML for my own application?
OIDC. It is JSON and JWT over plain HTTP redirects, every framework has a maintained library, and it works for mobile and APIs. Keep SAML for service providers that only speak SAML, and for brokering customers whose IdP setup is SAML-only. XML signature handling has a long history of bypasses, such as the ruby-saml parser differential disclosed in March 2025.
How do I give each customer their own SSO without a realm per customer?
Create one identity provider per customer inside a shared realm, then route by email domain with kc_idp_hint. Keycloak also has an Organizations feature for mapping domains to providers. A realm per customer multiplies upgrade and configuration drift work, so reserve it for customers who need separate branding, policies or data isolation.
When I set up Keycloak for a product, I check three things before anything else: that the discovery document returns the public https issuer, that /admin/ is unreachable from outside, and that realm config lives in Git rather than in someone's browser history. Everything else in this guide is tuning. The primary references are the Keycloak server guides, the server administration guide and the Operator guides. For tenancy design around all this, see multi-tenant SaaS architecture.
Related: Multi-Tenant SaaS Architecture Explained: Silo, Pool, Bridge, Cells (2026), MCP Authorization Explained: OAuth 2.1, RFC 9728 and CIMD in 2026 and Who Is This Agent? Non-Human Identity in 2026, From the RFCs to the Breaches.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
Enterprise-Ready SaaS in 2026: SSO, SCIM, Audit Logs and the SSO Tax
What enterprise procurement actually asks a SaaS vendor for in 2026: SAML and OIDC SSO, SCIM provisioning, audit logs kept six months, RBAC and data residency, plus the SSO tax debate and real 2026 pricing for WorkOS, Auth0, Clerk and Keycloak.
11 minOct 31, 2026How to Implement SOC 2 Controls on AWS with Terraform (2026)
Step-by-step SOC 2 technical controls on AWS with Terraform: organization CloudTrail, AWS Config and a custom conformance pack, GuardDuty, Security Hub CSPM with CIS v5.0.0, Identity Center MFA, encryption defaults, AWS Backup vault lock, 400-day retention and evidence export.
18 minOct 31, 2026How to Secure a Supabase App Before Launch: RLS Checklist (2026)
A step-by-step Supabase security checklist for 2026: enable RLS on every table, write auth.uid() policies, handle the 30 October grants change, move to publishable and secret keys, lock storage buckets, protect Edge Function secrets and test as anon.
13 min