How to Set Up Keycloak 26.8 for OIDC and SAML SSO: Step-by-Step (2026)

Oct 31, 2026 · 13 min · Ajay Kumar

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.

One realm, two client protocols, one broker hop to the customer's IdP Your app (OIDC client) auth code + PKCE S256 exact redirect URI client_id: webapp Legacy SP (SAML client) client ID = SP entity ID ACS URL registered signed assertions Keycloak 26.8.1 realm: acme (not master) PostgreSQL 18 TLS at reverse proxy proxy-headers xforwarded health on port 9000 /realms/acme/... Customer IdP (broker) Okta, Entra ID, Google OIDC or SAML upstream /broker/ALIAS/endpoint Customer directory pushes users and groups SCIM 2.0 bearer token /realms/acme/scim/v2 Expose /realms/ and /resources/ publicly. Keep /admin/ and /realms/master/ internal (Keycloak reverse proxy guide).
The setup built in this guide. Paths and the public versus internal split come from the Keycloak reverse proxy and SCIM documentation for 26.8.

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. Setting hostname-admin alone 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 stateless multi-cluster mode supported for teams that span regions.
  • Export realms to Git (kc.sh export --dir ./export --realm acme, ideally with nodes stopped, or a KeycloakRealmImport resource) 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