PhilippTheServer

Self-Hosted Identity with Keycloak: Central Offboarding and Forward Auth

Services stop having users; they have sessions handed to them by something else.

The problem

Count the places a person’s account exists at a typical organisation: the wiki, the monitoring dashboard, the git server, the registry, the ticket system, the VPN. Each one with its own password, its own idea of who is an admin, and its own list nobody has audited recently.

Now someone leaves. What is the procedure? If the honest answer involves a checklist and a search across a dozen admin panels, the accounts you forget will outlive the employment. That is not a hypothetical risk — it is the normal outcome of letting every service manage its own users, and it is easy to miss because each individual service looks fine in isolation. The gap only shows up as a whole, and by then it is an account nobody remembers granting.

Working through it

Services stop having users; they have sessions

With a central identity provider, a person exists in one place. Disable them there and every service that trusts the provider goes dark for that person at once — not because each service was individually updated, but because none of them ever held a password to begin with. Group membership becomes the thing that grants access, so “who can reach production” is a question with an answer, rather than an investigation across a dozen systems.

OIDC in one paragraph, and why it matters

A user hits a service, the service redirects them to the identity provider, they authenticate there, and they come back with a signed token asserting who they are. The service validates the signature and trusts the claim — it never sees the password, cannot leak what it never stored, and has no opinion about password policy, because that lives in one place, maintained by people who thought about it. The practical consequence: services stop being credential stores. Most internal tools have unremarkable auth code written once and never revisited; replacing it with “validate this token” is a real reduction in the amount of security code anyone is responsible for.

Modelling: resist the urge to be expressive

An identity provider will happily support fine-grained roles, nested groups, attribute mapping and per-client scopes, and it is possible to build something so expressive that nobody can answer “what can this person do”. Start with groups that mirror how access is actually talked about — if the sentence is “the infrastructure team can reach the cluster”, make a group with that name, and grant things to the group, never to an individual. An individual grant is invisible in every review and outlives the reason it was made.

Services that were never going to speak OIDC

Some things cannot do modern auth — old admin panels, appliances, a dashboard with one shared password. The pattern that covers them is a forward-auth proxy: the thing in front of the service asks an authentication service whether the request carries a valid session, and only passes it through if the answer is yes. The application behind it is untouched — it does not know authentication happened and does not need to. The caveat is that the proxy is now load-bearing: a second route that bypasses it is a route with no authentication at all, and deserves the same scrutiny as a firewall rule.

The solution

A realm import, a client, and a forward-auth proxy in front of an application with no login of its own — all runnable locally.

// realm-export.json
{
  "realm": "demo",
  "enabled": true,
  "sslRequired": "none",
  "clients": [
    {
      "clientId": "forward-auth-demo",
      "enabled": true,
      "publicClient": false,
      "secret": "forward-auth-demo-secret-dev-only",
      "redirectUris": ["http://localhost:4180/oauth2/callback"],
      "webOrigins": ["http://localhost:4180"],
      "standardFlowEnabled": true,
      "directAccessGrantsEnabled": true
    }
  ],
  "groups": [{ "name": "infra-team" }],
  "users": [
    {
      "username": "demo-user",
      "enabled": true,
      "emailVerified": true,
      "email": "demo-user@example.com",
      "groups": ["/infra-team"],
      "credentials": [
        { "type": "password", "value": "demo-user-dev-only", "temporary": false }
      ]
    }
  ]
}
# docker-compose.yml
services:
  keycloak-db:
    image: postgres:16
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak-dev-only
    volumes:
      - kc-db:/var/lib/postgresql/data
 
  keycloak:
    image: quay.io/keycloak/keycloak:26.0
    command: ["start-dev", "--import-realm"]
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak-dev-only
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin-dev-only
      KC_HOSTNAME: localhost
      KC_HTTP_ENABLED: "true"
    volumes:
      - ./realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
    ports: ["8080:8080"]
    depends_on: [keycloak-db]
 
  whoami:
    image: traefik/whoami:v1.10
 
  forward-auth:
    image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
    command:
      - --http-address=0.0.0.0:4180
      - --upstream=http://whoami:80
      - --provider=oidc
      - --oidc-issuer-url=http://keycloak:8080/realms/demo
      - --client-id=forward-auth-demo
      - --client-secret=forward-auth-demo-secret-dev-only
      - --redirect-url=http://localhost:4180/oauth2/callback
      - --email-domain=*
      - --cookie-secret=${OAUTH2_PROXY_COOKIE_SECRET}
      - --insecure-oidc-allow-unverified-email=true
    ports: ["4180:4180"]
    depends_on: [keycloak]
 
volumes:
  kc-db:
export OAUTH2_PROXY_COOKIE_SECRET=$(python3 -c 'import os,base64;print(base64.urlsafe_b64encode(os.urandom(16)).decode())')
docker compose up -d
# wait for Keycloak to report ready
curl --retry 20 --retry-delay 3 --retry-connrefused -s http://localhost:8080/realms/demo > /dev/null

First, the application behind forward auth is unreachable without a session — the whoami container has no idea authentication exists:

curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4180/
# 302   -- redirected to Keycloak's login page, never reaching whoami

Second, the login itself works, proven by fetching a real token directly:

curl -s -X POST http://localhost:8080/realms/demo/protocol/openid-connect/token \
  -d grant_type=password \
  -d client_id=forward-auth-demo \
  -d client_secret=forward-auth-demo-secret-dev-only \
  -d username=demo-user \
  -d password=demo-user-dev-only | jq -r '.access_token' | cut -c1-24
# eyJhbGciOiJSUzI1NiIsInR5...

Third, and this is the point of the whole article: one disable call, and the same login stops working everywhere that trusts this realm, not just in one place someone remembered to update.

ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/realms/master/protocol/openid-connect/token \
  -d grant_type=password -d client_id=admin-cli \
  -d username=admin -d password=admin-dev-only | jq -r '.access_token')
 
USER_ID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
  "http://localhost:8080/admin/realms/demo/users?username=demo-user" | jq -r '.[0].id')
 
curl -s -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"enabled": false}' \
  "http://localhost:8080/admin/realms/demo/users/$USER_ID"
 
curl -s -X POST http://localhost:8080/realms/demo/protocol/openid-connect/token \
  -d grant_type=password -d client_id=forward-auth-demo \
  -d client_secret=forward-auth-demo-secret-dev-only \
  -d username=demo-user -d password=demo-user-dev-only
# {"error":"invalid_grant","error_description":"Account disabled"}

That last response is offboarding in one action: no per-service checklist, no wondering about the dashboard someone forgot. Every service asking this realm the same question gets the same answer at once.

Conclusion

A central identity provider becomes a dependency of everything, which is the whole point and also the whole risk. When it is down, nothing is reachable — not because those services broke, but because nobody can prove who they are. That needs monitoring that checks the login flow end to end, and a documented, tested way in when it is down that does not itself depend on the thing that is broken.

Sessions and disabling are not the same instant. Disabling a user does not necessarily terminate an already-issued token immediately — the demo above shows the token endpoint refusing a disabled user, which is the guarantee that matters for anything logging in from now on. Find out what your token lifetime actually guarantees before you rely on it during an incident.

A forward-auth proxy is only as strong as its only path. Anything that reaches the protected application without going through the proxy — a second ingress, a directly exposed port — has no authentication at all, regardless of how well the proxy itself is configured.

Would I do this again — immediately, and not chiefly for single sign-on, which is a pleasant side effect. The property worth having is that a person is one object, and access is one decision.