keycloak-doctor GitHub ↗

keycloak-doctor

Audit a Keycloak realm for the mistakes that actually get exploited — from an export file or a live server, in one static Go binary.

Browse the 30 rulesInstallAudit an export

Keycloak gives you every switch you need to be secure, and no opinion about how they should be set. A realm accumulates a wildcard redirect URI here, a public client with the password grant there, brute force detection nobody re-enabled after a load test, a 1024-bit signing key inherited from a 2019 migration. Nothing warns you: each of those is a valid configuration.

keycloak-doctor reads the realm and reports the ones that matter, with the reason and the fix:

$ keycloak-doctor audit realm-export.json --min-severity warn
keycloak-doctor 0.1.0 · 1 realm · 30 rule(s) · file:realm-export.json · 1ms

BAD    client/redirect-wildcard        demo · legacy-frontend   redirect URI "https://*" matches hosts the client does not control
       → list the exact callback URLs instead; Keycloak matches them literally
BAD    client/pkce                     demo · legacy-frontend   a public client runs the code flow without requiring PKCE
       → set the PKCE method to S256 in the client's Advanced settings
BAD    keys/rsa-size                   demo · legacy-rsa        key provider "legacy-rsa" signs with a 1024-bit RSA key
       → create a 2048-bit (or larger) provider, make it active, and keep the old one passive only until the tokens it signed expire
BAD    realm/brute-force               demo                     brute force detection is disabled: password guessing is unthrottled
       → enable Brute force detection in Realm settings › Security defenses
WARN   realm/browser-mfa               demo                     the bound browser flow "browser" has no OTP or WebAuthn step: a password is the only factor
       → add a Conditional OTP or WebAuthn subflow to the browser flow

17 BAD · 13 WARN — worst: BAD

It is not a scanner and not a policy engine: no agents, no server, no CRDs, no cluster. One binary, two inputs, three output formats.

Documentation, with the searchable rule reference: https://allan-nava.github.io/keycloak-doctor/.

What it checks#

30 rules in 7 categories. The full catalogue with the rationale for each is in docs/rules.md — browsable and filterable on the rule reference — and it also lives in the binary:

$ keycloak-doctor rules --only client
CategoryRulesExamples
realm13brute force off, unrevocable 2h access tokens, offline tokens that never expire, no password policy, no second factor in the bound browser flow, events not recorded
client8wildcard and plaintext redirect URIs, * web origins, implicit flow, password grant on a public client, missing PKCE, full scope on a service account, hidden token-lifespan overrides
mapper2a mapper copying api_key/password_hash into a token claim, an audience mapper pointing at an API the client does not own
idp2broker trusted for unverified email while login-by-email is on, broker endpoints over plaintext HTTP
keys2RSA signing keys under 2048 bits, HMAC secrets under 32 bytes
federation2LDAP over ldap:// without StartTLS, LDAP TLS with certificate verification off
source1how much credential material the audited source itself carries

Severity means what it says: BAD is exploitable as configured, WARN is a weakening you should be able to justify, OK is a rule that ran and passed, and ERROR is a rule that could not run — a section the credentials were not allowed to read. A blind spot never renders as a clean bill.

Install#

Homebrew#

brew tap Allan-Nava/keycloak-doctor https://github.com/Allan-Nava/keycloak-doctor
brew install keycloak-doctor

The tap is this repository — the URL is not optional, because the repo is not named homebrew-*. The formula cannot go to homebrew-core: that tap only accepts OSI-approved open-source licences, and this project is source-available under PolyForm Noncommercial. It builds from the tagged source, so Homebrew pulls Go in as a build dependency and keycloak-doctor version reports the real version rather than dev.

Docker#

docker run --rm -v "$PWD:/realm:ro" ghcr.io/allan-nava/keycloak-doctor \
  audit /realm/prod-realm.json

About 6 MB, built FROM scratch: the static binary and the root certificates, and nothing else — an image that is handed a production realm has no business also carrying a shell and a package manager. It runs as uid 65532, needs no writable filesystem, and never writes to its input, so mount the export read-only.

Against a live server, the secret still travels by environment variable, never as a flag value:

docker run --rm -e KC_AUDIT_SECRET ghcr.io/allan-nava/keycloak-doctor \
  audit --url https://sso.example.com --realm prod \
  --client-id keycloak-doctor --client-secret-env KC_AUDIT_SECRET

To keep the JSON report, mount a writable directory and point --out-file inside it: -v "$PWD/out:/out" … --output json --out-file /out/audit.json.

go install#

go install github.com/Allan-Nava/keycloak-doctor/cmd/keycloak-doctor@latest

From a release, or from a checkout#

Static binaries for linux, macOS and Windows on amd64 and arm64 are attached to every release, with a checksums.txt to verify them. Or build it yourself:

go build -o keycloak-doctor ./cmd/keycloak-doctor

Use it#

Offline, against an export (no credentials involved)#

kc.sh export --realm prod --file prod-realm.json     # or the admin console's Partial export
keycloak-doctor audit prod-realm.json
keycloak-doctor audit ./export-dir --realm prod      # a directory export works too

A realm export carries plaintext client secrets, LDAP bind credentials and broker secrets. keycloak-doctor drops every credential value at load time and keeps only the fact that one was there — nothing downstream (a finding, the JSON output, an error message) can print one. The source/secret-material rule tells you how much the file you just audited is worth to an attacker, which is usually the reminder people need before attaching it to a ticket.

Live, against a running server#

export KC_AUDIT_SECRET=...      # never passed as a flag value
keycloak-doctor audit --url https://sso.example.com --realm prod \
  --client-id keycloak-doctor --client-secret-env KC_AUDIT_SECRET

--all-realms audits every realm the credentials can see. A password grant works too (--username admin --password-env KC_ADMIN_PASSWORD, against admin-cli by default).

Least privilege: create a confidential client with a service account and give it the read-only realm-management roles view-realm, view-clients and view-identity-providers for the realm you are auditing. Nothing the tool does needs write access. If a section is still out of reach, the rules over it report ERROR — not evaluated instead of passing.

In CI#

- uses: Allan-Nava/keycloak-doctor@v0.2.0
  with:
    path: realms/prod-realm.json
    exit-on: bad
- uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: keycloak-doctor.sarif

The findings become code scanning alerts on the repository that holds the realm definition, next to the file, instead of a report somebody has to dig out of a job log. The action downloads the release binary and verifies it against the release checksums, so the step costs about a second.

Exit codes follow the same rule as the rest of the family: 0 even when there are WARN/BAD findings — an audit that ran is a success, and the report is the deliverable. Non-zero only for systemic errors (unreadable source, credentials that do not work, unknown rule, bad flag). Pass --exit-on warn|bad|error when you want a gate, with --exit-code N to pick the code.

A realm that has accumulated findings cannot be fixed in one pull request, so gate on what changed instead, and accept the rest explicitly, with an expiry date and a reason:

keycloak-doctor audit prod-realm.json --output json --out-file audit-baseline.json   # once
keycloak-doctor audit prod-realm.json --baseline audit-baseline.json --exit-on bad --fail-on-new
keycloak-doctor audit prod-realm.json --suppress suppressions.json --exit-on bad

A suppression is never silent (the run reports how many findings it removed) and never permanent (past its date it stops suppressing, and suppression/expired says which entry lapsed). The whole story — the action's inputs and outputs, the SARIF level mapping, how a baseline matches a finding, the suppression file format — is in docs/ci.md.

Useful flags: --output text|markdown|json|sarif, --min-severity warn, --only client,keys, --skip realm/audit-events, --baseline audit.json, --fail-on-new, --suppress suppressions.json, --out-file PATH, --no-color.

The markdown output is shaped for a report or a PR comment: summary, a Needs attention list, then the full table. The json output is the gating contract — worst, summary and one object per finding with its stable rule id — and it is what --baseline reads back.

Design notes#

License#

Source-available under the PolyForm Noncommercial License 1.0.0: free for personal projects, research, education, non-profits and public institutions. For commercial use — auditing the realms of a company, or embedding this in a product or service — see COMMERCIAL.md.

Part of a family of domain-specific operations tooling: checkfleet (infrastructure health checks), segcheck (HLS/DASH segment truth), nomad-lens · nats-lens · ansible-vars-lens (VS Code).