<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Managed Keycloak Hosting and Enterprise Keycloak Support Blog</title>
        <link>https://phasetwo.io/blog/</link>
        <description>Managed Keycloak Hosting and Enterprise Keycloak Support Blog</description>
        <lastBuildDate>Tue, 08 Sep 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <item>
            <title><![CDATA[Keycloak invalid_grant: the eight things it actually means]]></title>
            <link>https://phasetwo.io/blog/keycloak-invalid-grant/</link>
            <guid>https://phasetwo.io/blog/keycloak-invalid-grant/</guid>
            <pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Keycloak's invalid_grant covers eight unrelated failures. A tested map from every error_description string to its real cause, on Keycloak 26.7.3.]]></description>
            <content:encoded><![CDATA[<p>Keycloak returns <code>invalid_grant</code> for at least eight unrelated failures, and the error code
itself tells you nothing. The useful field is <code>error_description</code>, which Keycloak fills in
with a short string that maps almost one-to-one onto a cause:</p>
<div class="language-json codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-json codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token property">"error"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token string" style="color:rgb(195, 232, 141)">"invalid_grant"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token property">"error_description"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token string" style="color:rgb(195, 232, 141)">"Code not valid"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></div></code></pre></div></div>
<p><code>invalid_grant</code> is OAuth's designated bucket for "the grant you presented is no good", so
Keycloak uses it for expired codes, replayed codes, PKCE mismatches, rotated refresh tokens,
dead sessions, revoked offline tokens, and bad passwords alike. Read the description, find
it in the table below, stop guessing.</p>
<p>Everything here was run against <strong>Keycloak 26.7.3</strong> on 2026-09-07, with realm defaults
except where a test says otherwise.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-whole-map-in-one-table">The whole map, in one table<a href="https://phasetwo.io/blog/keycloak-invalid-grant/#the-whole-map-in-one-table" class="hash-link" aria-label="Direct link to The whole map, in one table" title="Direct link to The whole map, in one table" translate="no">​</a></h2>
<p>Every string below came out of a live server. <code>error</code> was <code>invalid_grant</code> and the status was
<code>400</code> for all of them.</p>
<table><thead><tr><th><code>error_description</code></th><th>Grant type</th><th>What actually happened</th></tr></thead><tbody><tr><td><code>Code not valid</code></td><td><code>authorization_code</code></td><td>Code expired <strong>or</strong> was already redeemed — same message for both</td></tr><tr><td><code>Incorrect redirect_uri</code></td><td><code>authorization_code</code></td><td><code>redirect_uri</code> at the token endpoint differs from the one sent to <code>/auth</code>, or was omitted</td></tr><tr><td><code>Auth error: Found different client_id in clientSession</code></td><td><code>authorization_code</code></td><td>A different client is trying to redeem the code</td></tr><tr><td><code>PKCE code verifier not specified</code></td><td><code>authorization_code</code></td><td>You sent a <code>code_challenge</code> but no <code>code_verifier</code></td></tr><tr><td><code>PKCE verification failed: Invalid code verifier</code></td><td><code>authorization_code</code></td><td>The verifier is malformed — wrong length or wrong character set</td></tr><tr><td><code>PKCE verification failed: Code mismatch</code></td><td><code>authorization_code</code></td><td>The verifier is well-formed but does not hash to the challenge</td></tr><tr><td><code>Maximum allowed refresh token reuse exceeded</code></td><td><code>refresh_token</code></td><td>Rotation is on and you replayed a superseded refresh token</td></tr><tr><td><code>Session doesn't have required client</code></td><td><code>refresh_token</code></td><td>The client session was torn down — usually by a replay a moment earlier</td></tr><tr><td><code>Session not active</code></td><td><code>refresh_token</code></td><td>The user session is gone: logout, or it was administratively removed</td></tr><tr><td><code>Token is not active</code></td><td><code>refresh_token</code></td><td>SSO idle or max lifespan exceeded, or clock skew between nodes</td></tr><tr><td><code>Stale token</code></td><td><code>refresh_token</code></td><td>A not-before policy was pushed after the token was issued</td></tr><tr><td><code>Offline user session not found</code></td><td><code>refresh_token</code></td><td>The offline session was revoked or aged out</td></tr><tr><td><code>Invalid refresh token</code></td><td><code>refresh_token</code></td><td>Not a refresh token at all — an access token or ID token, typically</td></tr><tr><td><code>Invalid refresh token. Token client and authorized client don't match</code></td><td><code>refresh_token</code></td><td>The refresh token belongs to a different client</td></tr><tr><td><code>Invalid user credentials</code></td><td><code>password</code></td><td>Wrong password, unknown user, <strong>or</strong> a brute-force lockout</td></tr><tr><td><code>Account disabled</code></td><td><code>password</code></td><td><code>enabled=false</code> on the user</td></tr><tr><td><code>Account is not fully set up</code></td><td><code>password</code></td><td>A required action is pending — <code>VERIFY_PROFILE</code>, <code>UPDATE_PASSWORD</code>, and friends</td></tr></tbody></table>
<p>The rest of this post is the eight causes those strings group into, in the order you are
likely to hit them.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="1-the-code-was-already-redeemed--and-it-just-killed-the-session">1. The code was already redeemed — and it just killed the session<a href="https://phasetwo.io/blog/keycloak-invalid-grant/#1-the-code-was-already-redeemed--and-it-just-killed-the-session" class="hash-link" aria-label="Direct link to 1. The code was already redeemed — and it just killed the session" title="Direct link to 1. The code was already redeemed — and it just killed the session" translate="no">​</a></h2>
<p>Redeem an authorization code twice and the second attempt fails, which is what you would
expect. What nobody expects is what happens to the tokens from the <em>first</em>, successful
exchange:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain"># 1. exchange the code — succeeds, we keep the refresh token</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">curl -s -X POST "$TOKEN_URL" -d grant_type=authorization_code -d client_id=web \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -d client_secret=$SECRET -d code=$CODE -d redirect_uri=http://localhost:3000/callback</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"># 2. exchange the same code again</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">{"error":"invalid_grant","error_description":"Code not valid"}</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"># 3. now use the refresh token that worked ten seconds ago</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">{"error":"invalid_grant","error_description":"Session doesn't have required client"}</span><br></div></code></pre></div></div>
<p>Keycloak treats a redeemed code turning up a second time as a stolen code, and detaches the
client from the user session. The legitimate client — the one that redeemed correctly — is
logged out as collateral.</p>
<p>This is why <code>Session doesn't have required client</code> shows up in bug reports as "users get
randomly logged out". It is almost never random. Something replayed a grant. The usual
culprits are a React effect firing twice under StrictMode, a callback route that runs on both
server and client, or a user refreshing the callback page.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="2-the-code-expired">2. The code expired<a href="https://phasetwo.io/blog/keycloak-invalid-grant/#2-the-code-expired" class="hash-link" aria-label="Direct link to 2. The code expired" title="Direct link to 2. The code expired" translate="no">​</a></h2>
<p>Same message, different cause. Authorization codes live for <code>accessCodeLifespan</code>, which the
admin console calls <strong>Client login timeout</strong> under Realm settings → Tokens, and which
<a href="https://www.keycloak.org/docs/latest/server_admin/index.html#_timeouts" target="_blank" rel="noopener noreferrer" class="">the server admin guide</a>
describes as the maximum time before clients must finish the authorization code flow. It
ships at <strong>60 seconds</strong>.</p>
<p>Sixty seconds is generous for a redirect and a back-channel call, and mean if your callback
handler blocks on something slow — a user lookup against a legacy directory, a cold Lambda,
a provisioning call. Do the token exchange first, everything else after.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="telling-the-two-apart">Telling the two apart<a href="https://phasetwo.io/blog/keycloak-invalid-grant/#telling-the-two-apart" class="hash-link" aria-label="Direct link to Telling the two apart" title="Direct link to Telling the two apart" translate="no">​</a></h3>
<p>The response is identical, and so is the server log: both produce
<code>type="CODE_TO_TOKEN_ERROR", error="invalid_code"</code> with no distinguishing reason. The
<code>code_id</code> detail is what separates them. Turn on event storage and read the sequence:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">kcadm.sh update events/config -r demo \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -s eventsEnabled=true \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -s 'enabledEventTypes=["LOGIN","CODE_TO_TOKEN","CODE_TO_TOKEN_ERROR"]'</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">kcadm.sh get events -r demo -q max=10</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain"># replayed code — a successful exchange for the same code_id</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">LOGIN                code_id=KP3veSsloJww7HFfIJF_5A4r</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">CODE_TO_TOKEN        code_id=KP3veSsloJww7HFfIJF_5A4r</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">CODE_TO_TOKEN_ERROR  code_id=KP3veSsloJww7HFfIJF_5A4r  error=invalid_code</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"># expired code — nothing ever redeemed it</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">LOGIN                code_id=oqgn0iFSvrCod8rFFN4jPXmJ</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">CODE_TO_TOKEN_ERROR  code_id=oqgn0iFSvrCod8rFFN4jPXmJ  error=invalid_code</span><br></div></code></pre></div></div>
<p>A <code>CODE_TO_TOKEN</code> between the <code>LOGIN</code> and the error means replay. No <code>CODE_TO_TOKEN</code> means
expiry. That single check has settled more of these arguments than any amount of reading
application logs.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="3-the-redirect_uri-changed-between-the-two-calls">3. The <code>redirect_uri</code> changed between the two calls<a href="https://phasetwo.io/blog/keycloak-invalid-grant/#3-the-redirect_uri-changed-between-the-two-calls" class="hash-link" aria-label="Direct link to 3-the-redirect_uri-changed-between-the-two-calls" title="Direct link to 3-the-redirect_uri-changed-between-the-two-calls" translate="no">​</a></h2>
<div class="language-json codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-json codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token property">"error"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token string" style="color:rgb(195, 232, 141)">"invalid_grant"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token property">"error_description"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token string" style="color:rgb(195, 232, 141)">"Incorrect redirect_uri"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></div></code></pre></div></div>
<p>The <code>redirect_uri</code> in the token request must be byte-identical to the one in the
authorization request. Registering both on the client is not enough — we sent
<code>http://localhost:3000/callback</code> to <code>/auth</code> and <code>http://localhost:3000/cb2</code> to the token
endpoint with both registered, and it was rejected. Omitting the parameter entirely gives the
same error.</p>
<p>The server log spells it out where the response does not:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">error="invalid_redirect_uri", reason="Parameter 'redirect_uri' did not match originally</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">saved redirect URI used in initial OIDC request. Saved redirectUri:</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">http://localhost:3000/callback, redirectUri parameter: http://localhost:3000/cb2"</span><br></div></code></pre></div></div>
<p>This is a different failure from the one that stops you at the login screen. If you never got
a code at all, you want
<a class="" href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/">Invalid parameter: redirect_uri</a> instead.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="4-pkce--three-failures-three-distinct-messages">4. PKCE — three failures, three distinct messages<a href="https://phasetwo.io/blog/keycloak-invalid-grant/#4-pkce--three-failures-three-distinct-messages" class="hash-link" aria-label="Direct link to 4. PKCE — three failures, three distinct messages" title="Direct link to 4. PKCE — three failures, three distinct messages" translate="no">​</a></h2>
<p>Keycloak is unusually helpful here, and the three messages mean genuinely different things.</p>
<table><thead><tr><th>Message</th><th>Meaning</th></tr></thead><tbody><tr><td><code>PKCE code verifier not specified</code></td><td>The <code>code_verifier</code> parameter is missing from the token request</td></tr><tr><td><code>PKCE verification failed: Invalid code verifier</code></td><td>The verifier does not satisfy RFC 7636 syntax</td></tr><tr><td><code>PKCE verification failed: Code mismatch</code></td><td>The verifier is syntactically fine but hashes to a different challenge</td></tr></tbody></table>
<p>The syntax rule is a verifier of 43–128 characters from the unreserved set. We confirmed the
boundary against a live server: a 43-character verifier that is simply wrong returns
<code>Code mismatch</code>, and the same wrong verifier at 42 characters returns <code>Invalid code verifier</code>.</p>
<p>That distinction is worth knowing, because it tells you which bug you have. <code>Invalid code verifier</code> means your generator is broken — usually base64 instead of base64url, or truncating
to 32 characters. <code>Code mismatch</code> means the generator is fine and you lost the verifier
between the two requests: regenerated it, stored it in a cookie that got dropped on the
cross-site redirect, or ran the two halves of the flow on different instances of a
horizontally scaled app.</p>
<p>A missing <code>code_challenge</code> on the <em>authorization</em> request is not an <code>invalid_grant</code> at all.
Keycloak fails it earlier, redirecting back with
<code>error=invalid_request&amp;error_description=Missing+parameter%3A+code_challenge_method</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="5-refresh-token-rotation-and-the-concurrency-trap">5. Refresh token rotation and the concurrency trap<a href="https://phasetwo.io/blog/keycloak-invalid-grant/#5-refresh-token-rotation-and-the-concurrency-trap" class="hash-link" aria-label="Direct link to 5. Refresh token rotation and the concurrency trap" title="Direct link to 5. Refresh token rotation and the concurrency trap" translate="no">​</a></h2>
<p>This one is worth measuring, because the setting that causes it is frequently turned on for
good reasons by someone who then spends a week debugging the result.</p>
<p>With <strong>Revoke Refresh Token</strong> (<code>revokeRefreshToken</code>) enabled, each refresh invalidates the
token you presented. <strong>Refresh Token Max Reuse</strong> (<code>refreshTokenMaxReuse</code>) controls how many
replays are tolerated, and defaults to <code>0</code> — none. Replay a superseded token and you get:</p>
<div class="language-json codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-json codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token property">"error"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token string" style="color:rgb(195, 232, 141)">"invalid_grant"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token property">"error_description"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token string" style="color:rgb(195, 232, 141)">"Maximum allowed refresh token reuse exceeded"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></div></code></pre></div></div>
<p>Now fire three refreshes concurrently with the same token, which is exactly what a browser
app with three tabs, or a service with a connection pool, does the moment an access token
expires:</p>
<table><thead><tr><th>Realm setting</th><th>Result of 3 concurrent refreshes with one token</th></tr></thead><tbody><tr><td><code>revokeRefreshToken=false</code> (shipped default)</td><td>3 × new token issued</td></tr><tr><td><code>revokeRefreshToken=true</code>, <code>refreshTokenMaxReuse=0</code></td><td>1 × new token, 1 × <code>Maximum allowed refresh token reuse exceeded</code>, 1 × <code>Session doesn't have required client</code></td></tr><tr><td><code>revokeRefreshToken=true</code>, <code>refreshTokenMaxReuse=2</code></td><td>3 × new token issued</td></tr></tbody></table>
<p>Read the middle row carefully. The third request does not merely fail — by the time it
arrives the reuse has already detached the client session, so the <em>new</em> refresh token
returned to the first caller is dead too. One racing tab logs the user out of all of them.</p>
<p>Rotation is a real security control and we are not telling you to leave it off. But if you
turn it on, either serialise refreshes behind a single-flight lock in every client, or set
<code>refreshTokenMaxReuse</code> to a small non-zero number and accept a narrow replay window. Turning
rotation on and changing nothing else is the option that produces support tickets.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="6-the-session-is-gone">6. The session is gone<a href="https://phasetwo.io/blog/keycloak-invalid-grant/#6-the-session-is-gone" class="hash-link" aria-label="Direct link to 6. The session is gone" title="Direct link to 6. The session is gone" translate="no">​</a></h2>
<p>Four different messages, all meaning "the session behind this refresh token no longer
exists":</p>
<ul>
<li class=""><strong><code>Session not active</code></strong> — someone hit the logout endpoint, or an admin removed the session.</li>
<li class=""><strong><code>Token is not active</code></strong> — the session passed <code>ssoSessionIdleTimeout</code> (default 30 minutes)
or <code>ssoSessionMaxLifespan</code> (default 10 hours). We reproduced this by dropping idle timeout
to 60 seconds and waiting it out.</li>
<li class=""><strong><code>Stale token</code></strong> — a not-before policy was pushed. Realm settings → Sessions → Revocation,
or the per-client equivalent, sets a timestamp and every token issued before it dies
instantly. Easy to forget you clicked it.</li>
<li class=""><strong><code>Offline user session not found</code></strong> — an offline token whose session was revoked, either
by the revocation endpoint or by the user removing consent.</li>
</ul>
<p><code>Token is not active</code> is also what clock skew looks like. If it appears immediately after
issuing a token, on a multi-node cluster, on tokens that should have 29 minutes left, check
that the nodes agree on the time before you touch any Keycloak setting. We did not reproduce
that case directly — it needs two nodes with genuinely divergent clocks — but the failure
mode is the same message, and comparing <code>date</code> across nodes rules it in or out in a second.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="7-the-direct-grant-where-the-message-is-deliberately-unhelpful">7. The direct grant, where the message is deliberately unhelpful<a href="https://phasetwo.io/blog/keycloak-invalid-grant/#7-the-direct-grant-where-the-message-is-deliberately-unhelpful" class="hash-link" aria-label="Direct link to 7. The direct grant, where the message is deliberately unhelpful" title="Direct link to 7. The direct grant, where the message is deliberately unhelpful" translate="no">​</a></h2>
<p>The password grant returns three messages, and one of them is a lie by design:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">wrong password:      {"error":"invalid_grant","error_description":"Invalid user credentials"}</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">unknown user:        {"error":"invalid_grant","error_description":"Invalid user credentials"}</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">disabled user:       {"error":"invalid_grant","error_description":"Account disabled"}</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">required action:     {"error":"invalid_grant","error_description":"Account is not fully set up"}</span><br></div></code></pre></div></div>
<p><code>Invalid user credentials</code> covers wrong password and non-existent user identically, so the
endpoint cannot be used to enumerate accounts. It also covers a <strong>brute-force lockout</strong>. With
<code>bruteForceProtected=true</code> and <code>failureFactor=3</code>, we sent four bad passwords and then the
correct one:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">attempt 4 (bad):                 {"error_description":"Invalid user credentials"}</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">attempt 5 (CORRECT password):    {"error_description":"Invalid user credentials"}</span><br></div></code></pre></div></div>
<p>The admin guide is explicit that this masking is intentional, so an attacker cannot tell a
locked account from a wrong password. Neither can your support team. The server log does
distinguish them:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">error="invalid_user_credentials"   ← wrong password</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">error="user_temporarily_disabled"  ← locked out</span><br></div></code></pre></div></div>
<p>And you can check and clear a specific user's lockout state directly:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">kcadm.sh get attack-detection/brute-force/users/$USER_ID -r demo</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">kcadm.sh delete attack-detection/brute-force/users/$USER_ID -r demo</span><br></div></code></pre></div></div>
<p><code>Account is not fully set up</code> is the one people misdiagnose most. It means a required action
is pending — and on a fresh realm, <code>VERIFY_PROFILE</code> fires for any user missing an email,
first name, or last name. Users created by script or by a migration hit this constantly while
looking perfectly fine in the console.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="8-that-is-not-this-clients-refresh-token">8. That is not this client's refresh token<a href="https://phasetwo.io/blog/keycloak-invalid-grant/#8-that-is-not-this-clients-refresh-token" class="hash-link" aria-label="Direct link to 8. That is not this client's refresh token" title="Direct link to 8. That is not this client's refresh token" translate="no">​</a></h2>
<p>Two messages, both meaning the token you presented is not the thing the endpoint wanted:</p>
<ul>
<li class=""><strong><code>Invalid refresh token</code></strong> — you sent something that is not a refresh token. Posting an
access token here is the single most common version, usually a variable mix-up. Decode the
<code>typ</code> claim; a refresh token says <code>Refresh</code> (or <code>Offline</code>), an access token says <code>Bearer</code>.</li>
<li class=""><strong><code>Invalid refresh token. Token client and authorized client don't match</code></strong> — the token is
real but was issued to a different <code>client_id</code>. Common when a gateway and a frontend share
token storage, or when someone copies a token between environments.</li>
</ul>
<p>If you are not sure what you are holding, decode it. Our
<a class="" href="https://phasetwo.io/tutorials/getting-started/your-first-token/">token walkthrough tutorial</a> covers reading
every claim, including the ones that make valid tokens get rejected.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="things-that-look-like-invalid_grant-and-are-not">Things that look like <code>invalid_grant</code> and are not<a href="https://phasetwo.io/blog/keycloak-invalid-grant/#things-that-look-like-invalid_grant-and-are-not" class="hash-link" aria-label="Direct link to things-that-look-like-invalid_grant-and-are-not" title="Direct link to things-that-look-like-invalid_grant-and-are-not" translate="no">​</a></h2>
<p>Worth stating plainly, because the outline for this post originally listed the first one as
an <code>invalid_grant</code> cause and testing said otherwise:</p>
<table><thead><tr><th>Failure</th><th>What you actually get</th></tr></thead><tbody><tr><td>Wrong client secret</td><td><code>401</code> + <code>{"error":"unauthorized_client","error_description":"Invalid client or Invalid client credentials"}</code></td></tr><tr><td>Missing <code>code_challenge</code> when PKCE is required</td><td><code>302</code> back to the app with <code>error=invalid_request</code></td></tr><tr><td>Direct access grants disabled on the client</td><td><code>unauthorized_client</code></td></tr></tbody></table>
<p>If you are looking at <code>unauthorized_client</code> or <code>invalid_client</code>, stop reading this post — the
problem is client authentication, not the grant.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-short-version">The short version<a href="https://phasetwo.io/blog/keycloak-invalid-grant/#the-short-version" class="hash-link" aria-label="Direct link to The short version" title="Direct link to The short version" translate="no">​</a></h2>
<ol>
<li class="">Read <code>error_description</code>, not <code>error</code>. It names the cause.</li>
<li class=""><code>Code not valid</code> is expired or replayed; check the event log for a <code>CODE_TO_TOKEN</code> with
the same <code>code_id</code> to tell which.</li>
<li class=""><code>Session doesn't have required client</code> means something replayed a grant a moment earlier.
Find the double request; do not raise a timeout.</li>
<li class="">Three PKCE messages, three different bugs. <code>Invalid code verifier</code> is your generator,
<code>Code mismatch</code> is your storage.</li>
<li class="">If you enable refresh token rotation, fix concurrent refreshes at the same time.</li>
<li class=""><code>Invalid user credentials</code> might be a lockout. Only the server log knows.</li>
</ol>
<p>Keep <code>docker logs</code> on the Keycloak container open in a second terminal while you debug any of
this. The response is deliberately vague; the log is not.</p>
<hr>
<p>Most of these are one-line configuration answers once you know which of the eight you are
looking at — the expensive part is the afternoon spent finding out. If you would rather not
spend it, Phase Two runs <a class="" href="https://phasetwo.io/hosting/dedicated-clusters/">managed Keycloak</a> with a 30-day
Starter trial from $149/month, and the session and token settings above already tuned. If you
are staying self-hosted, the <a class="" href="https://phasetwo.io/tutorials/">Keycloak tutorials</a> and the
<a class="" href="https://phasetwo.io/docs/securing-applications/">securing applications docs</a> cover the client-side half of this.</p>]]></content:encoded>
            <category>keycloak</category>
            <category>troubleshooting</category>
            <category>oidc</category>
            <category>oauth2</category>
            <category>tokens</category>
        </item>
        <item>
            <title><![CDATA[Keycloak Skills and the Phase Two MCP Server]]></title>
            <link>https://phasetwo.io/blog/keycloak-skills-mcp-launch/</link>
            <guid>https://phasetwo.io/blog/keycloak-skills-mcp-launch/</guid>
            <pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Two launches in one — an open-source Agent Skills plugin that teaches Claude how to configure Keycloak properly, and the Phase Two Keycloak MCP server with 158 admin tools. Install it in two commands.]]></description>
            <content:encoded><![CDATA[<p>Today we're launching <strong><a href="https://github.com/p2-inc/keycloak-skills" target="_blank" rel="noopener noreferrer" class=""><code>keycloak-skills</code></a></strong> — an open-source Agent Skills plugin that teaches Claude how to configure Keycloak <em>correctly</em> — and the <strong>Phase Two Keycloak MCP server</strong>, 158 admin tools that let it do the work against a live cluster instead of just telling you what to type.</p>
<p>Two commands to install. Works against any Keycloak — and gets sharper the closer you get to ours. Don't have a Keycloak yet? The agent can provision one for you and it's free for 30 days.</p>
<figure><img src="https://phasetwo.io/blog/keycloak_skills_mcp/session-hero.png" alt="A Claude Code session configuring magic-link passwordless login on a Phase Two Keycloak realm through the Keycloak MCP server"><figcaption>Ask for the outcome; the skill picks the recipe and the MCP tools do the work.</figcaption></figure>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-problem-with-asking-an-ai-about-keycloak">The problem with asking an AI about Keycloak<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#the-problem-with-asking-an-ai-about-keycloak" class="hash-link" aria-label="Direct link to The problem with asking an AI about Keycloak" title="Direct link to The problem with asking an AI about Keycloak" translate="no">​</a></h2>
<p>Keycloak is enormously powerful, and enormously easy to misconfigure in ways that produce <strong>no error at all</strong>. Ask a general-purpose model to "set up passwordless login" and you will usually get something that looks right, returns <code>204 No Content</code> from every call, and is quietly broken.</p>
<p>A few real examples, all of which we've fixed in production for customers:</p>
<ul>
<li class="">Mix <code>ALTERNATIVE</code> and <code>REQUIRED</code> executions at the same level of an authentication flow and Keycloak <strong>silently erases the alternatives</strong>. This is how "primary login, then a choice of second factors" gets built wrong.</li>
<li class="">The Admin REST calls that create flow executions <strong>do not establish their order</strong>. You have to read the flow back and repair it. Order <em>is</em> the behavior in an <code>ALTERNATIVE</code> block — first to succeed wins.</li>
<li class="">Put stock <code>auth-username-form</code> in front of an email-OTP step and it rejects unknown addresses with "Invalid username or email" <em>before</em> the OTP step runs — leaking which email addresses have accounts.</li>
<li class="">Copy a generic OIDC attribute mapper into a "Sign in with GitHub" setup and it maps <strong>nothing</strong>: GitHub sends <code>login</code>, not <code>preferred_username</code>, and needs its own <code>github-user-attribute-mapper</code>.</li>
<li class="">Keycloak's <code>partialImport</code> endpoint accepts authentication flows with an HTTP 200 and creates none of them.</li>
<li class="">For an IdP-initiated SSO tile, two client attributes silently outrank the redirect you configured, and <code>RelayState</code> — which looks exactly like the routing mechanism — is discarded outright.</li>
<li class=""><code>CONFIGURE_TOTP</code> ships <strong>registered but disabled</strong>. A required action you set on a user is accepted by the API and then never prompts.</li>
</ul>
<p>None of that is guessable. It's the accumulated scar tissue of years of running Keycloak, and it's exactly what we've distilled into these skills — 30 reference chapters for realm administration, 18 for application integration, roughly 12,000 lines of verified guidance.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="install-it">Install it<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#install-it" class="hash-link" aria-label="Direct link to Install it" title="Direct link to Install it" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">claude plugin marketplace add p2-inc/keycloak-skills</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">claude plugin install phasetwo@keycloak-skills</span><br></div></code></pre></div></div>
<p>Or interactively inside a Claude Code session: <code>/plugin marketplace add p2-inc/keycloak-skills</code>, then <code>/plugin install phasetwo</code>. Restart Claude Code afterwards — skills load at session start.</p>
<p>The plugin <strong>declares the MCP server for you</strong>, so there's nothing else to run. The first tool call opens a browser to authorize; check the connection any time with <code>/mcp</code>. If you want the server on its own, without the skills:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">claude mcp add --transport http keycloak https://mcp.phasetwo.io/mcp</span><br></div></code></pre></div></div>
<figure><img src="https://phasetwo.io/blog/keycloak_skills_mcp/plugin-install.png" alt="The Claude Code plugin browser showing the phasetwo plugin in the keycloak-skills marketplace, with two skills and one MCP server"><figcaption>Two skills and the Keycloak MCP server, installed together — no separate setup step.</figcaption></figure>
<p>Then just ask for what you want — "add passwordless login by magic link", "connect our customer's Okta by email domain", "add login to this React app". The skill figures out which of its chapters applies, asks the one or two questions it actually needs, and goes.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="watch-it-work">Watch it work<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#watch-it-work" class="hash-link" aria-label="Direct link to Watch it work" title="Direct link to Watch it work" translate="no">​</a></h2>
<p>Razvan from our team takes an unsecured Angular app and simply says <em>"I want to secure this app"</em> — and the skill does the rest: it asks the one question that matters (Phase Two hosted, or self-managed?), picks the cluster and realm over MCP, registers the OIDC client, rewrites the app's own configuration to match, and then the protected route logs in for real against the cluster. No documentation, no admin console, no copy-pasting a client secret between two browser tabs.</p>
<iframe width="1048" height="681" src="https://www.youtube.com/embed/w_TAEKUzV4Q" title="Phase Two MCP introduction" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin"></iframe>
<p>What the walkthrough covers:</p>
<ul>
<li class=""><strong>Prerequisites</strong> — an agent, the plugin installed, and a Phase Two subscription with a cluster. (The cluster can be created through the plugin too, as above; the demo uses an existing one to keep things moving.)</li>
<li class=""><strong>Authorizing against Phase Two</strong> — the MCP server's OAuth login, so every subsequent call is made as him.</li>
<li class=""><strong>The skill routing the request</strong> — <code>securing-apps</code> recognizing an Angular app and asking the hosted-vs-self-managed question before touching anything.</li>
<li class=""><strong>Driving the cluster over MCP</strong> — listing his clusters, choosing the target realm, and registering the client in it.</li>
<li class=""><strong>The payoff</strong> — the app's config updated in place, and a real login completed at the protected endpoint.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="any-keycloak--and-better-the-closer-you-get-to-ours">Any Keycloak — and better the closer you get to ours<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#any-keycloak--and-better-the-closer-you-get-to-ours" class="hash-link" aria-label="Direct link to Any Keycloak — and better the closer you get to ours" title="Direct link to Any Keycloak — and better the closer you get to ours" translate="no">​</a></h2>
<p>There are three rungs here, and you get real value on the first one.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="rung-1--any-keycloak-at-all">Rung 1 — any Keycloak at all<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#rung-1--any-keycloak-at-all" class="hash-link" aria-label="Direct link to Rung 1 — any Keycloak at all" title="Direct link to Rung 1 — any Keycloak at all" translate="no">​</a></h3>
<p><strong>The skills work against any Keycloak installation.</strong> Bare metal, Docker, Kubernetes, someone else's managed offering. Every chapter ships in two variants and the skill asks you one question to pick between them: <code>rest</code> drives the Keycloak Admin REST API with your own admin token, <code>mcp</code> drives the Phase Two MCP server. The Keycloak knowledge is identical either way.</p>
<p>Plenty of the catalogue is pure stock Keycloak with no extensions involved: passkey-only WebAuthn login, credential enrollment for existing users, social login buttons, enterprise IdP federation, IdP-initiated SSO tiles, and every last thing in <code>securing-apps</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="rung-2--phase-twos-enhanced-keycloak-distribution">Rung 2 — Phase Two's enhanced Keycloak distribution<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#rung-2--phase-twos-enhanced-keycloak-distribution" class="hash-link" aria-label="Direct link to Rung 2 — Phase Two's enhanced Keycloak distribution" title="Direct link to Rung 2 — Phase Two's enhanced Keycloak distribution" translate="no">​</a></h3>
<p>Swap your image for <strong><a href="https://quay.io/repository/phasetwo/phasetwo-keycloak?tab=tags" target="_blank" rel="noopener noreferrer" class=""><code>quay.io/phasetwo/phasetwo-keycloak</code></a></strong> and the rest of the catalogue opens up. It's Keycloak with our popular <a href="https://phasetwo.io/docs/introduction/open-source/" target="_blank" rel="noopener noreferrer" class="">open-source extensions</a> already bundled — <a href="https://github.com/p2-inc/keycloak-orgs" target="_blank" rel="noopener noreferrer" class="">organizations</a>, <a href="https://github.com/p2-inc/keycloak-magic-link" target="_blank" rel="noopener noreferrer" class="">magic link</a>, <a href="https://github.com/p2-inc/keycloak-events" target="_blank" rel="noopener noreferrer" class="">events and webhooks</a>, <a href="https://github.com/p2-inc/keycloak-themes" target="_blank" rel="noopener noreferrer" class="">attribute-driven themes</a>, admin UI and portal customizations. One line of Helm values or one <code>docker run</code>; examples live in <a href="https://github.com/p2-inc/phasetwo-containers" target="_blank" rel="noopener noreferrer" class="">phasetwo-containers</a>.</p>
<p>That single change unlocks magic-link login, emailed OTP both passwordless <em>and</em> as a second factor, organizations with all three membership-gated login variants, and corporate SSO routed by email domain — none of which exist in stock Keycloak at any price.</p>
<p>And this is exactly where the skills earn their keep, because these flows are assembled from authenticators (<code>ext-magic-form</code>, <code>ext-email-otp</code>, <code>ext-select-org</code>, <code>ext-auth-username-auth-note</code>, <code>ext-auth-home-idp-discovery</code>) whose <strong>order relative to one another is the behavior</strong>, and which no amount of general Keycloak knowledge will teach you. Put the organization check after the magic-link step and a non-member still gets a login email. Reach for stock <code>auth-username-form</code> instead of the identifier-only authenticator and you leak which addresses have accounts. The skills know these orderings because we've gotten them wrong first.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="rung-3--phase-two-hosted">Rung 3 — Phase Two hosted<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#rung-3--phase-two-hosted" class="hash-link" aria-label="Direct link to Rung 3 — Phase Two hosted" title="Direct link to Rung 3 — Phase Two hosted" translate="no">​</a></h3>
<p>On a <strong>hosted cluster</strong> the MCP server is there too, and Claude stops writing <code>curl</code> commands for you to run and starts operating the realm directly:</p>
<table><thead><tr><th>Domain</th><th>Tools</th></tr></thead><tbody><tr><td><strong>Authentication flows</strong></td><td>Create, copy, and author flows; add authenticators, sub-flows and conditionals; set requirements; reorder executions; import a whole flow atomically; bind realm, client and IdP-broker flows</td></tr><tr><td><strong>Identity providers</strong></td><td>Okta OIDC/SAML, generic OIDC/SAML, built-in social providers, attribute and role mappers, IdP↔organization linking</td></tr><tr><td><strong>Clients &amp; scopes</strong></td><td>OIDC and SAML client registration, secret rotation, protocol mappers, client scopes, login themes, and <code>explainTokenClaims</code> to show <em>why</em> a claim is in a token</td></tr><tr><td><strong>Users, roles, groups</strong></td><td>Search, create, update, passwords, lockout status, required-action emails, realm roles and composites, groups, sessions and forced logout, LDAP user federation with connection test and sync</td></tr><tr><td><strong>Realm settings</strong></td><td>Login and registration, SMTP, WebAuthn passwordless policy, password policy, brute-force protection, themes</td></tr><tr><td><strong>Organizations</strong></td><td>Full <a href="https://github.com/p2-inc/keycloak-orgs" target="_blank" rel="noopener noreferrer" class="">keycloak-orgs</a> surface — CRUD, domains, members, org roles, invitations</td></tr><tr><td><strong>Events &amp; webhooks</strong></td><td>Event settings, webhook subscriptions, delivery attempts, secrets</td></tr><tr><td><strong>Clusters &amp; deployments</strong></td><td>List and provision clusters, regions, custom domains and their status, environment variables, IP rules, deployments, restart status</td></tr></tbody></table>
<p>158 tools in all. Two details worth calling out:</p>
<p><strong>Every call is authorized as <em>you</em>.</strong> The MCP server stores no admin credentials of its own — it acts as an OAuth 2.1 resource server, and control-plane calls are made with the bearer token of the human logged into the MCP client. Reaching into a cluster deployment mints a short-lived, deployment-scoped admin token from that same authorization, so your existing roles and permissions apply and the audit trail shows you, not a shared robot.</p>
<p><strong>It refuses to delete things that matter.</strong> Deleting a cluster, deployment or realm is irreversible — a deployment <em>is</em> a realm, so it takes every user, client and flow with it. <code>deleteCluster</code> refuses every call and never reaches the API, no tool deletes a deployment or realm at all, and the skill's very first instruction is to deny the request and point you at the dashboard rather than reach for <code>curl</code>. Changing a client that already serves traffic requires your confirmation, and the skills verify by reading configuration back rather than trusting a <code>204</code>.</p>
<figure><img src="https://phasetwo.io/blog/keycloak_skills_mcp/deletion-refused.png" alt="The skill declining a request to delete a Keycloak realm, explaining that deletion is console-only and pointing at the Phase Two dashboard"><figcaption>Destructive requests are denied by the skill and by the server — not left to the model's judgment.</figcaption></figure>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="no-keycloak-at-all-ask-the-agent-for-one">No Keycloak at all? Ask the agent for one<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#no-keycloak-at-all-ask-the-agent-for-one" class="hash-link" aria-label="Direct link to No Keycloak at all? Ask the agent for one" title="Direct link to No Keycloak at all? Ask the agent for one" translate="no">​</a></h3>
<p>Here's our favorite part. You don't need an existing cluster to start — <strong>the agent can provision you a Starter cluster, on a 30-day free trial, without you leaving the session.</strong></p>
<p>Say "spin up a Phase Two cluster" and the skill walks the whole thing: identifies you from your token, finds the organization that will own the cluster, lists the available regions, checks your chosen name is free, and calls <code>createCluster</code> on the <strong>Starter</strong> tier — <a href="https://phasetwo.io/blog/starter-tier-launch/" target="_blank" rel="noopener noreferrer" class="">$149/month with a 30-day free trial</a>. It hands you the Stripe checkout link to open yourself (it will never enter payment details on your behalf — that's an explicit rule in the skill, not a limitation), polls <code>getCluster</code> until the status goes <code>BILLING_SETUP → PROVISIONING → ACTIVE</code>, then offers to create your first deployment and point a custom domain at it.</p>
<p>And then it keeps going. Same session, same agent: "now add passwordless login by magic link" against the cluster it just built for you. From nothing to a running, observable, passwordless Keycloak in one conversation.</p>
<figure><img src="https://phasetwo.io/blog/keycloak_skills_mcp/cluster-provisioning.png" alt="The agent provisioning a Phase Two Starter cluster: name, region and tier chosen, a Stripe checkout link returned, and the cluster status polled through to ACTIVE"><figcaption>From no Keycloak to an ACTIVE cluster, in the session — the agent hands off payment and never touches it.</figcaption></figure>
<div class="theme-admonition theme-admonition-info admonition_Y66Y alert alert--info"><div class="admonitionHeading_Feou"><span class="admonitionIcon_RBEH"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>info</div><div class="admonitionContent_oUgd"><p><strong>Try the whole thing free</strong>
Install the plugin, then ask for a cluster. Starter includes a <strong>30-day free trial</strong>, so the entire path — provision, configure, wire up your app — costs nothing to walk end to end.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="we-run-our-support-desk-on-this">We run our support desk on this<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#we-run-our-support-desk-on-this" class="hash-link" aria-label="Direct link to We run our support desk on this" title="Direct link to We run our support desk on this" translate="no">​</a></h2>
<p>This isn't a demo we built for a launch post. Phase Two's support team uses these skills and this MCP server every day for <strong>customer support, debugging, and hands-on assistance across our fleet of Keycloak clusters, for hundreds of customers</strong>.</p>
<p>That's the real reason the guidance is shaped the way it is. Every silent failure in the list above cost somebody an afternoon before it became a paragraph in a reference file. When a customer opens a ticket saying "MFA isn't prompting" or "the SSO redirect goes to the wrong place," the fastest path to an answer is an agent that already knows the twelve ways each of those goes wrong.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="verified-not-vibed">Verified, not vibed<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#verified-not-vibed" class="hash-link" aria-label="Direct link to Verified, not vibed" title="Direct link to Verified, not vibed" translate="no">​</a></h2>
<p>Skill content is only as good as its testing. Each capability ships with a <a href="https://github.com/benchflow-ai/skillsbench" target="_blank" rel="noopener noreferrer" class="">skillsbench</a> task in the repo's <a href="https://github.com/p2-inc/keycloak-skills/tree/main/benchmarks" target="_blank" rel="noopener noreferrer" class=""><code>benchmarks/</code></a> directory that stands up a real Keycloak in a sandbox and drives an actual login — magic links clicked, passkeys signed by a headless browser's virtual authenticator, a second realm standing in for a partner's IdP for genuine brokered SSO.</p>
<p>The assertions are deliberately adversarial, because the plausible-but-wrong answer is the enemy. The email-OTP-as-MFA task asserts that a <strong>wrong password sends no mail at all</strong> — a flow that merely puts a code step in front of a login passes a happy-path test while leaving the password irrelevant. The credential-enrollment task asserts that a user who had no password still has <strong>none</strong> at the end, because setting a temporary one satisfies a naive reading of the goal and defeats its entire point.</p>
<figure><img src="https://phasetwo.io/blog/keycloak_skills_mcp/benchmark-run.png" alt="skillsbench verifier output for the Keycloak email-OTP MFA task, showing passing assertions including that a wrong password sends no email"><figcaption>Each capability ships with a sandboxed task that drives a real login and asserts the negative cases too.</figcaption></figure>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-it-covers-today">What it covers today<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#what-it-covers-today" class="hash-link" aria-label="Direct link to What it covers today" title="Direct link to What it covers today" translate="no">​</a></h2>
<p><strong>Realm administration</strong> (the <code>keycloak</code> skill):</p>
<ul>
<li class="">Passwordless login — magic link, emailed OTP code, passkey-only WebAuthn, or the combined "0 password required" flow offering a passkey <em>or</em> a magic link</li>
<li class="">Emailed OTP as a <strong>second factor</strong> behind a still-required password</li>
<li class="">Credential enrollment for users who already exist — required actions, or an emailed enrollment link</li>
<li class="">Corporate SSO routed by email domain (home realm discovery)</li>
<li class="">Social login buttons — Google, GitHub, Microsoft, Facebook and the rest</li>
<li class="">Enterprise IdP federation — Entra ID, Okta, Auth0, ADFS, AWS SSO, Google Workspace, PingOne, OneLogin, Oracle, Duo, CyberArk, JumpCloud, LastPass, Salesforce, Cloudflare Access, or any generic OIDC/SAML 2.0 IdP</li>
<li class="">IdP-initiated SSO from an Okta or Entra portal tile into one specific app</li>
<li class="">Organization-membership login restriction — for local password login, for federated/SSO login, and for magic-link login</li>
<li class="">Phase Two cluster provisioning and new deployments</li>
</ul>
<p>The magic-link, email-OTP, organization and domain-routed-SSO rows need our extensions — so rung 2 or 3 above. Everything else is stock Keycloak.</p>
<p><strong>Application integration</strong> (the <code>securing-apps</code> skill): browser login, logout and route protection for React, Angular, Vue, Next.js and vanilla SPAs; bearer-JWT validation for Spring Boot, Express, FastAPI and Quarkus resource servers; native login for Android, iOS and React Native; plus registering the OIDC client each one needs — and diagnosing the classics (<code>invalid redirect_uri</code>, a 401 from your API, CORS on the token call, redirect loops, missing roles).</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="not-covered-tell-us--thats-the-point">Not covered? Tell us — that's the point<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#not-covered-tell-us--thats-the-point" class="hash-link" aria-label="Direct link to Not covered? Tell us — that's the point" title="Direct link to Not covered? Tell us — that's the point" translate="no">​</a></h2>
<p>The router is deliberately honest about its edges. Ask for something it doesn't cover and it will <strong>say so</strong> rather than force your request into the nearest-looking recipe and hand you confidently wrong guidance. It will then offer to draft a GitHub issue for you, quoting your request verbatim — because the exact phrasing you used is precisely what the next version needs in order to recognize the case.</p>
<p>So: <strong><a href="https://github.com/p2-inc/keycloak-skills/issues" target="_blank" rel="noopener noreferrer" class="">file an issue in <code>p2-inc/keycloak-skills</code></a></strong>. Missing capability, a chapter that's wrong for your Keycloak version, a tool you wish the MCP server had — all of it is useful, and it's how this grows. New capabilities land as reference chapters under the same skill once they're genuinely written and verified.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="where-this-is-going">Where this is going<a href="https://phasetwo.io/blog/keycloak-skills-mcp-launch/#where-this-is-going" class="hash-link" aria-label="Direct link to Where this is going" title="Direct link to Where this is going" translate="no">​</a></h2>
<p>We're committing to this: <strong>distilling years of Keycloak expertise into skills and MCP tools, in the open, so everyone benefits.</strong> Keycloak's power has always come with a learning curve measured in months. Encoding that knowledge where your agent can read it is the most direct way we know to flatten it — for our customers, and for the much larger community running Keycloak themselves.</p>
<p>Two commands, and your agent knows Keycloak.</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">claude plugin marketplace add p2-inc/keycloak-skills</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">claude plugin install phasetwo@keycloak-skills</span><br></div></code></pre></div></div>
<hr>
<p>Star and fork the repo at <a href="https://github.com/p2-inc/keycloak-skills" target="_blank" rel="noopener noreferrer" class="">p2-inc/keycloak-skills</a>. Running your own Keycloak? Pull <a href="https://quay.io/repository/phasetwo/phasetwo-keycloak?tab=tags" target="_blank" rel="noopener noreferrer" class=""><code>quay.io/phasetwo/phasetwo-keycloak</code></a> and the whole catalogue becomes available. Don't have a Keycloak at all? Install the plugin and just ask for one — a <a href="https://dash.phasetwo.io/clusters" target="_blank" rel="noopener noreferrer" class="">Starter cluster</a> comes with a 30-day free trial and the agent can provision it for you. Questions, or want to tell us what to build next? <a href="mailto:support@phasetwo.io" target="_blank" rel="noopener noreferrer" class="">support@phasetwo.io</a>.</p>]]></content:encoded>
            <category>phase_two</category>
            <category>keycloak</category>
            <category>mcp</category>
            <category>claude</category>
            <category>agent-skills</category>
            <category>ai</category>
            <category>open_source</category>
            <category>sso</category>
            <category>passwordless</category>
            <category>dedicated-clusters</category>
        </item>
        <item>
            <title><![CDATA[Keycloak Custom Domains Can Now Serve App Association Files]]></title>
            <link>https://phasetwo.io/blog/app-association-custom-domains/</link>
            <guid>https://phasetwo.io/blog/app-association-custom-domains/</guid>
            <pubDate>Sat, 05 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Phase Two custom domains can now serve apple-app-site-association, assetlinks.json and a change-password redirect, so mobile apps can autofill saved passwords and share passkeys with a Keycloak login page.]]></description>
            <content:encoded><![CDATA[<p><strong>Custom domains on Phase Two can now serve the files iOS and Android use to link a domain to a mobile app.</strong> Upload them from the dashboard and they are live in minutes — no deploy, no cluster restart.</p>
<p>That closes a gap that had nothing to do with Keycloak's capabilities and everything to do with where Keycloak sits in a mobile login flow.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-gap">The gap<a href="https://phasetwo.io/blog/app-association-custom-domains/#the-gap" class="hash-link" aria-label="Direct link to The gap" title="Direct link to The gap" translate="no">​</a></h2>
<p>Mobile platforms decide whether an app may use a saved credential by checking the <strong>domain that served the login page</strong>. If your users sign in at <code>auth.example.com</code>, that is the origin the password manager files the credential under, and the origin iOS and Android will consult later.</p>
<p>Both platforms consult it the same way: they fetch a file from a fixed path at the root of that domain, out of band, and check whether it names your app.</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">https://auth.example.com/.well-known/apple-app-site-association</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">https://auth.example.com/.well-known/assetlinks.json</span><br></div></code></pre></div></div>
<p>Which puts the identity provider in an awkward position. Keycloak serves paths under <code>/realms/</code>, <code>/admin/</code> and <code>/resources/</code> — it has never owned the root of the domain, and on deployments that run it under a path prefix it has no root to own at all. So the files could not come from Keycloak, and they could not come from the app team either, because the app team does not control the login domain.</p>
<p>The result was a silent failure. Autofill never appeared, nothing logged an error, and teams reasonably concluded it was a device setting.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-we-changed">What we changed<a href="https://phasetwo.io/blog/app-association-custom-domains/#what-we-changed" class="hash-link" aria-label="Direct link to What we changed" title="Direct link to What we changed" translate="no">​</a></h2>
<p>Phase Two custom domains now serve three things at the root of the domain:</p>
<table><thead><tr><th>Path</th><th>Purpose</th></tr></thead><tbody><tr><td><code>/.well-known/apple-app-site-association</code></td><td>Links the domain to your iOS app</td></tr><tr><td><code>/.well-known/assetlinks.json</code></td><td>Links the domain to your Android app</td></tr><tr><td><code>/.well-known/change-password</code></td><td>Where password managers send someone to change their password</td></tr></tbody></table>
<p>They are served at the edge rather than by the cluster, which has a few consequences worth knowing. Uploads apply in minutes instead of needing a restart, so correcting a fingerprint is not a deployment event. They keep serving during a cluster restart, which matters because a failed fetch gets cached by Apple for hours. And each domain on a cluster gets its own content, which is what makes this work for white-label products where every tenant has a different app.</p>
<p>Everything else under <code>/.well-known/</code> still returns 404 — deliberately. The change-password specification relies on an unknown well-known URL <em>not</em> returning 200, and a catch-all would quietly break how password managers detect support.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="why-we-think-this-belongs-in-the-platform">Why we think this belongs in the platform<a href="https://phasetwo.io/blog/app-association-custom-domains/#why-we-think-this-belongs-in-the-platform" class="hash-link" aria-label="Direct link to Why we think this belongs in the platform" title="Direct link to Why we think this belongs in the platform" translate="no">​</a></h2>
<p>Phase Two exists because running Keycloak well involves a long tail of work that has nothing to do with Keycloak itself. Certificates, custom domains, upgrades, themes, extension packaging — each is small, each is somebody's afternoon, and collectively they are the reason teams end up staffing an identity platform rather than using one.</p>
<p>App association files are a clean example. The requirement comes from Apple and Google, the fix is two small JSON files, and the only reason it is hard is that the files have to live somewhere Keycloak has never served from. Nothing about it is intellectually interesting. It is just infrastructure that has to exist, and it sits precisely at the boundary where a hosted provider can help and an app team cannot.</p>
<p>Passkeys make it more pressing. A passkey is bound to a relying party ID, which is a domain, and that binding is fixed when the credential is created — you cannot change it later without invalidating every passkey already enrolled. Teams planning passkeys in a native app need the domain and the association files settled <em>before</em> the first enrolment, not after.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="getting-started">Getting started<a href="https://phasetwo.io/blog/app-association-custom-domains/#getting-started" class="hash-link" aria-label="Direct link to Getting started" title="Direct link to Getting started" translate="no">​</a></h2>
<p><strong>Setting up the domain</strong> — adding a custom domain, DNS and certificate issuance, and where to upload the files in the dashboard: <a class="" href="https://phasetwo.io/docs/self-service/custom-domains/#app-association-files">Custom Domains</a>. Available on Premium and Enterprise plans.</p>
<p><strong>Writing the files</strong> — Team IDs, package names, signing fingerprints, the entitlement on the app side, and how to verify with Apple's and Google's public validators before shipping a build: <a class="" href="https://phasetwo.io/tutorials/mobile-apps/app-association-files/">Write the App Association Files That Let Mobile Apps Autofill Your Login</a>. That tutorial is vendor-neutral and works against any Keycloak.</p>
<p>One thing worth reading before you start, because it surprises most people: <strong>subdomains do not inherit</strong>. A file on <code>example.com</code> does not cover <code>auth.example.com</code>. iOS offers a wildcard entitlement and Android offers a delegating <code>include</code>, but as of this writing at least one file generally has to be served from the domain your login page actually runs on. Platform behaviour here does change — check the vendor documentation before relying on either exception.</p>]]></content:encoded>
            <category>keycloak</category>
            <category>custom-domains</category>
            <category>passkeys</category>
            <category>mobile</category>
            <category>autofill</category>
        </item>
        <item>
            <title><![CDATA[SCIM Explained: What It Is, When You Need It, and How Keycloak Does It]]></title>
            <link>https://phasetwo.io/blog/scim-explained/</link>
            <guid>https://phasetwo.io/blog/scim-explained/</guid>
            <pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[What SCIM actually is, when you need it instead of SSO alone, and a working walkthrough of Keycloak's native SCIM API — tested on 26.7.3, with real requests and responses.]]></description>
            <content:encoded><![CDATA[<p><strong>SCIM — System for Cross-domain Identity Management — is a standard REST API for creating, updating, and deactivating user accounts across systems.</strong> SSO answers "can this person log in?" SCIM answers "does this person have an account at all, and should they still?"</p>
<p>If you sell to enterprises, you will be asked for it. This post covers what it is, when you actually need it, and a working walkthrough of Keycloak's native SCIM API — which arrived as a preview feature and is not enabled by default.</p>
<p>Everything here was run against <strong>Keycloak 26.7.3</strong>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-problem-scim-solves">What problem SCIM solves<a href="https://phasetwo.io/blog/scim-explained/#what-problem-scim-solves" class="hash-link" aria-label="Direct link to What problem SCIM solves" title="Direct link to What problem SCIM solves" translate="no">​</a></h2>
<p>Without it, enterprise onboarding looks like this: someone joins, IT adds them to Okta, and then a human logs into your app and creates their account too. Someone leaves, IT disables their Okta account — and the account in your app stays active until somebody remembers.</p>
<p>That gap is the whole reason SCIM exists. It is a security problem before it is a convenience problem: <strong>the leaver whose account never got deactivated is one of the most common ways access outlives employment.</strong></p>
<p>SCIM standardises four operations across vendors:</p>
<table><thead><tr><th>Event in the IdP</th><th>SCIM call to your app</th></tr></thead><tbody><tr><td>Employee joins</td><td><code>POST /Users</code></td></tr><tr><td>Details change</td><td><code>PATCH /Users/{id}</code></td></tr><tr><td>Joins a team</td><td><code>PATCH /Groups/{id}</code></td></tr><tr><td>Employee leaves</td><td><code>PATCH /Users/{id}</code> setting <code>active: false</code></td></tr></tbody></table>
<p>Note the last one. <strong>SCIM deactivates; it usually does not delete.</strong> Most IdPs send <code>active: false</code> rather than <code>DELETE</code>, because audit trails need the record to survive. Systems that treat deprovisioning as deletion get this wrong and lose history.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="scim-vs-saml-vs-oidc--they-solve-different-problems">SCIM vs SAML vs OIDC — they solve different problems<a href="https://phasetwo.io/blog/scim-explained/#scim-vs-saml-vs-oidc--they-solve-different-problems" class="hash-link" aria-label="Direct link to SCIM vs SAML vs OIDC — they solve different problems" title="Direct link to SCIM vs SAML vs OIDC — they solve different problems" translate="no">​</a></h2>
<p>This is the most common confusion, and the comparison is not apples to apples:</p>
<table><thead><tr><th></th><th>SAML / OIDC</th><th>SCIM</th></tr></thead><tbody><tr><td>Question answered</td><td>Can this person log in <em>right now</em>?</td><td>Should this person have an account?</td></tr><tr><td>When it runs</td><td>At login</td><td>Continuously, in the background</td></tr><tr><td>Direction</td><td>User's browser → your app</td><td>IdP server → your API</td></tr><tr><td>Without it</td><td>No SSO</td><td>Manual account admin, and stale leavers</td></tr></tbody></table>
<p>They are complementary, not alternatives. A typical enterprise deal wants <strong>both</strong>: SAML or OIDC so people can log in, SCIM so the account list stays correct.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="do-you-actually-need-it">Do you actually need it?<a href="https://phasetwo.io/blog/scim-explained/#do-you-actually-need-it" class="hash-link" aria-label="Direct link to Do you actually need it?" title="Direct link to Do you actually need it?" translate="no">​</a></h3>
<p>Be honest about this, because SCIM is real work:</p>
<p><strong>You probably need it if:</strong> you sell to companies with hundreds of employees; your buyers ask about "provisioning" or "deprovisioning"; you have per-seat pricing (customers want their seat count to track reality automatically); or you have compliance requirements around timely access removal.</p>
<p><strong>You probably don't yet if:</strong> your customers are small teams; users self-register; or just-in-time provisioning at first login is sufficient. <strong>JIT provisioning</strong> — creating the account the first time someone logs in via SSO — covers a lot of ground and costs almost nothing. It just cannot deprovision, because a leaver simply never logs in again.</p>
<p>That is the honest dividing line: <em>if you need deprovisioning, you need SCIM.</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="keycloaks-native-scim-api">Keycloak's native SCIM API<a href="https://phasetwo.io/blog/scim-explained/#keycloaks-native-scim-api" class="hash-link" aria-label="Direct link to Keycloak's native SCIM API" title="Direct link to Keycloak's native SCIM API" translate="no">​</a></h2>
<p>Keycloak ships a SCIM 2.0 server. Two things are not obvious:</p>
<ol>
<li class=""><strong>It is a preview feature</strong>, off by default.</li>
<li class=""><strong>It must also be enabled per realm</strong> — the feature flag alone gets you a 404.</li>
</ol>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="enable-it">Enable it<a href="https://phasetwo.io/blog/scim-explained/#enable-it" class="hash-link" aria-label="Direct link to Enable it" title="Direct link to Enable it" translate="no">​</a></h3>
<p>Start the server with the feature:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">docker run -p 8081:8080 \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  quay.io/keycloak/keycloak:26.7.3 start-dev --features=scim-api</span><br></div></code></pre></div></div>
<p>You'll see it confirmed at boot:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">INFO [org.keycloak.common.Profile] Preview features enabled: scim-api:v1</span><br></div></code></pre></div></div>
<p>Then enable it on the realm:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">kcadm.sh update realms/myrealm -s scimApiEnabled=true</span><br></div></code></pre></div></div>
<p>Miss that second step and every SCIM request returns <code>404</code>, while the server log says:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">WARN [org.keycloak.scim.services.ScimRealmResourceFactory] SCIM API is not enabled for realm 'myrealm'</span><br></div></code></pre></div></div>
<p>Your base URL is then:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">{server}/realms/{realm}/scim/v2</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="authenticate--and-the-audience-trap">Authenticate — and the audience trap<a href="https://phasetwo.io/blog/scim-explained/#authenticate--and-the-audience-trap" class="hash-link" aria-label="Direct link to Authenticate — and the audience trap" title="Direct link to Authenticate — and the audience trap" translate="no">​</a></h3>
<p>SCIM clients authenticate with a bearer token from a service account:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">kcadm.sh create clients -r myrealm \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -s clientId=scim-client -s publicClient=false \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -s serviceAccountsEnabled=true -s secret=CHANGEME</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">kcadm.sh add-roles -r myrealm --uusername service-account-scim-client \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  --cclientid realm-management \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  --rolename manage-users --rolename view-users --rolename query-users</span><br></div></code></pre></div></div>
<p>Now the part that will cost you an afternoon. The token needs an audience mapper, and <strong>the audience is not the client id — it is the SCIM base URL itself</strong>:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">kcadm.sh create clients/$CLIENT_UUID/protocol-mappers/models -r myrealm \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -s name=scim-audience -s protocol=openid-connect \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -s protocolMapper=oidc-audience-mapper \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -s 'config."included.custom.audience"=http://localhost:8081/realms/myrealm/scim/v2' \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -s 'config."access.token.claim"=true'</span><br></div></code></pre></div></div>
<p>We tried <code>scim</code>, <code>scim-api</code>, <code>urn:keycloak:scim</code>, and the client id. All of them return:</p>
<div class="language-json codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-json codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token property">"schemas"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token punctuation" style="color:rgb(199, 146, 234)">[</span><span class="token string" style="color:rgb(195, 232, 141)">"urn:ietf:params:scim:api:messages:2.0:Error"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">]</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"> </span><span class="token property">"status"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token string" style="color:rgb(195, 232, 141)">"401"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token property">"detail"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token string" style="color:rgb(195, 232, 141)">"Invalid token audience"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></div></code></pre></div></div>
<p>Only the full base URL works. Get a token the usual way:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">curl -X POST {server}/realms/myrealm/protocol/openid-connect/token \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -d grant_type=client_credentials -d client_id=scim-client -d client_secret=CHANGEME</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="check-what-it-supports-before-you-build">Check what it supports before you build<a href="https://phasetwo.io/blog/scim-explained/#check-what-it-supports-before-you-build" class="hash-link" aria-label="Direct link to Check what it supports before you build" title="Direct link to Check what it supports before you build" translate="no">​</a></h3>
<p><code>ServiceProviderConfig</code> is the first call any SCIM client makes, and for Keycloak 26.7.3 it returns some limitations worth knowing up front:</p>
<div class="language-json codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-json codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"patch"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain">          </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"> </span><span class="token property">"supported"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token boolean" style="color:rgb(255, 88, 116)">true</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"bulk"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain">           </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"> </span><span class="token property">"supported"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token boolean" style="color:rgb(255, 88, 116)">false</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token property">"maxOperations"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(247, 140, 108)">0</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"filter"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain">         </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"> </span><span class="token property">"supported"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token boolean" style="color:rgb(255, 88, 116)">true</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token property">"maxResults"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(247, 140, 108)">100</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"changePassword"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"> </span><span class="token property">"supported"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token boolean" style="color:rgb(255, 88, 116)">false</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"sort"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain">           </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"> </span><span class="token property">"supported"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token boolean" style="color:rgb(255, 88, 116)">false</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"etag"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain">           </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"> </span><span class="token property">"supported"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token boolean" style="color:rgb(255, 88, 116)">false</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></div></code></pre></div></div>
<p>Read that carefully:</p>
<ul>
<li class=""><strong><code>bulk</code> is not supported.</strong> An IdP syncing 10,000 users will make 10,000 calls. Plan for the rate, and for how your infrastructure handles a Monday-morning bulk sync.</li>
<li class=""><strong><code>sort</code> is not supported.</strong> Clients that assume stable ordering across pages need testing.</li>
<li class=""><strong><code>filter</code> caps at 100 results per page.</strong> Pagination is mandatory, not optional.</li>
<li class=""><strong><code>changePassword</code> is not supported</strong> — passwords stay with the IdP, which is correct.</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="it-works">It works<a href="https://phasetwo.io/blog/scim-explained/#it-works" class="hash-link" aria-label="Direct link to It works" title="Direct link to It works" translate="no">​</a></h3>
<p>Creating a user:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">curl -X POST "$BASE/Users" \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -H "Authorization: Bearer $TOKEN" \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -H "Content-Type: application/scim+json" \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -d '{</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    "userName":"bob","active":true,</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    "name":{"givenName":"Bob","familyName":"Example"},</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    "emails":[{"value":"bob@example.com","primary":true}]</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  }'</span><br></div></code></pre></div></div>
<div class="language-json codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-json codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"schemas"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">[</span><span class="token string" style="color:rgb(195, 232, 141)">"urn:ietf:params:scim:schemas:core:2.0:User"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">]</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"id"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"7b4b1d91-0ce6-44d0-9c77-766ae5dadcb3"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"meta"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"resourceType"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"User"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"created"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"2026-08-31T21:52:58.866Z"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"location"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">".../scim/v2/Users/7b4b1d91-0ce6-44d0-9c77-766ae5dadcb3"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"userName"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"bob"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"name"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"> </span><span class="token property">"formatted"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"Bob Example"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token property">"familyName"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"Example"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token property">"givenName"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"Bob"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"active"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token boolean" style="color:rgb(255, 88, 116)">true</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"emails"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">[</span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"> </span><span class="token property">"value"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"bob@example.com"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token property">"type"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"work"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token property">"primary"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token boolean" style="color:rgb(255, 88, 116)">true</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></div></code></pre></div></div>
<p>That user is a normal Keycloak user — <code>kcadm.sh get users</code> returns it immediately. SCIM is a protocol surface over the same user store, not a parallel one.</p>
<p>Filtering works as specified:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">curl -G "$BASE/Users" -H "Authorization: Bearer $TOKEN" \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  --data-urlencode 'filter=userName eq "bob"'</span><br></div></code></pre></div></div>
<div class="language-json codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-json codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"> </span><span class="token property">"schemas"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">[</span><span class="token string" style="color:rgb(195, 232, 141)">"urn:ietf:params:scim:api:messages:2.0:ListResponse"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">]</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"totalResults"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(247, 140, 108)">1</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token property">"itemsPerPage"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(247, 140, 108)">1</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> ... </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-supports-scim-should-mean-when-a-buyer-asks">What "supports SCIM" should mean when a buyer asks<a href="https://phasetwo.io/blog/scim-explained/#what-supports-scim-should-mean-when-a-buyer-asks" class="hash-link" aria-label="Direct link to What &quot;supports SCIM&quot; should mean when a buyer asks" title="Direct link to What &quot;supports SCIM&quot; should mean when a buyer asks" translate="no">​</a></h2>
<p>Vendors claim SCIM support at wildly different depths. If you are evaluating — or being evaluated — these are the questions that separate a real implementation from a checkbox:</p>
<ol>
<li class=""><strong>Users and groups, or users only?</strong> Group sync is where most implementations stop, and it is usually what the customer actually wants.</li>
<li class=""><strong>Does deactivation work?</strong> <code>active: false</code> must disable access immediately. This is the single most important operation and the most commonly half-implemented.</li>
<li class=""><strong>Is <code>PATCH</code> supported, or only <code>PUT</code>?</strong> Okta and Entra ID lean on <code>PATCH</code>. <code>PUT</code>-only support causes data loss when the IdP sends a partial update.</li>
<li class=""><strong>Which filters?</strong> <code>eq</code> is table stakes. Real IdPs use more.</li>
<li class=""><strong>How does it handle a user who already exists?</strong> Duplicate handling is where sync loops are born.</li>
<li class=""><strong>Is there rate limiting, and what happens on bulk sync?</strong> Especially with no bulk endpoint.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="where-this-leaves-you">Where this leaves you<a href="https://phasetwo.io/blog/scim-explained/#where-this-leaves-you" class="hash-link" aria-label="Direct link to Where this leaves you" title="Direct link to Where this leaves you" translate="no">​</a></h2>
<p>Keycloak's native SCIM API is real, it works, and it is preview — which means it may change before it stabilises, and you should not assume the audience quirk above is permanent. For a new integration it is the right place to start.</p>
<p>The gap it does not close is <strong>multi-tenant</strong> provisioning: a B2B SaaS usually needs each customer organization to have its own SCIM endpoint and its own credentials, so one customer's IdP cannot see or touch another's users. That is a different shape of problem to realm-level SCIM, and it is why we built <a class="" href="https://phasetwo.io/docs/organizations/scim/">SCIM for Organizations</a> as an extension.</p>
<p>If you're implementing SCIM against Keycloak, start with the native API above and reach for per-organization endpoints when you have more than one customer wanting to sync.</p>
<hr>
<p>Further reading: <a href="https://datatracker.ietf.org/doc/html/rfc7644" target="_blank" rel="noopener noreferrer" class="">RFC 7644</a> is the SCIM protocol specification and is unusually readable. Keycloak's own <a href="https://www.keycloak.org/docs/latest/server_admin/index.html" target="_blank" rel="noopener noreferrer" class="">SCIM documentation</a> covers the full endpoint surface.</p>
<p>Running Keycloak in production — including preview features you'd rather not operate yourself? <a class="" href="https://phasetwo.io/hosting/dedicated-clusters/">That's what we do</a>.</p>]]></content:encoded>
            <category>keycloak</category>
            <category>scim</category>
            <category>provisioning</category>
            <category>standards</category>
            <category>organizations</category>
        </item>
        <item>
            <title><![CDATA[Atomic authentication flow updates for Keycloak, built with Gusto]]></title>
            <link>https://phasetwo.io/blog/keycloak-atomic-auth-flows/</link>
            <guid>https://phasetwo.io/blog/keycloak-atomic-auth-flows/</guid>
            <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A new open-source extension that imports Keycloak authentication flows, configs, and bindings in a single atomic transaction — built in partnership with Gusto and in production for almost a year.]]></description>
            <content:encoded><![CDATA[<p>We're open-sourcing <a href="https://github.com/p2-inc/keycloak-atomic-auth-flows" target="_blank" rel="noopener noreferrer" class=""><code>keycloak-atomic-auth-flows</code></a>, a Keycloak extension that replaces an entire set of authentication flows, authenticator configs, and their bindings in <strong>one atomic, transactional request</strong>.</p>
<p>It was built in partnership with <a href="https://gusto.com/" target="_blank" rel="noopener noreferrer" class="">Gusto</a>, who use Keycloak for user authentication and rely on Phase Two for enterprise Keycloak support. It has been running in their production environment for almost a year. The public repository is the extraction of that work into a standalone extension anyone can install.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-problem-there-is-no-transaction-around-a-flow-change">The problem: there is no transaction around a flow change<a href="https://phasetwo.io/blog/keycloak-atomic-auth-flows/#the-problem-there-is-no-transaction-around-a-flow-change" class="hash-link" aria-label="Direct link to The problem: there is no transaction around a flow change" title="Direct link to The problem: there is no transaction around a flow change" translate="no">​</a></h2>
<p>Authentication flows are the highest-stakes configuration in a Keycloak realm. If a flow is wrong, nobody logs in.</p>
<p>They are also the configuration that Keycloak gives you the least protection while changing. Both the admin console and the admin REST API mutate flows <strong>incrementally</strong>: adding an execution is one call, setting its requirement is another, reordering is another, creating a subflow and moving executions into it is several more. Each of those calls commits on its own, against a flow that is already bound as the realm's browser flow and already serving live login traffic.</p>
<p>That leaves a window — sometimes minutes long, if a human is clicking through the console — where the flow that real users are authenticating against is in a state nobody designed. A subflow that exists but is empty. A <code>REQUIRED</code> OTP execution added before its authenticator config. An <code>ALTERNATIVE</code> removed before its replacement was added. Every one of those intermediate states is a live flow, and some of them fail closed.</p>
<p>The usual workarounds don't close the window either:</p>
<ul>
<li class=""><strong>Realm partial import</strong> doesn't cover authentication flows in a way you can use for iterative updates.</li>
<li class=""><strong>Copy the built-in flow, edit the copy, re-bind</strong> is the right instinct, and it's what careful operators do by hand. But it's a manual, multi-step, admin-console-driven process, and nothing about it is repeatable from a pipeline.</li>
<li class=""><strong>Full realm export/import</strong> is far too blunt for a flow change and has its own consistency problems.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-the-extension-does">What the extension does<a href="https://phasetwo.io/blog/keycloak-atomic-auth-flows/#what-the-extension-does" class="hash-link" aria-label="Direct link to What the extension does" title="Direct link to What the extension does" translate="no">​</a></h2>
<p>The extension registers a realm admin REST resource that takes the whole flow tree as a single payload:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">POST /admin/realms/{realm}/authentication-flow/import[?force=true]</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">Content-Type: application/json</span><br></div></code></pre></div></div>
<p>The body carries everything needed for a complete change in one shot:</p>
<table><thead><tr><th>Field</th><th>What it sets</th></tr></thead><tbody><tr><td><code>authenticationFlows</code></td><td>The flows to create, including all of their executions and subflows</td></tr><tr><td><code>authenticatorConfig</code></td><td>The authenticator configs the executions reference</td></tr><tr><td><code>browserFlowBinding</code></td><td>The flow to bind as the realm's browser flow</td></tr><tr><td><code>idpFlowBindings</code></td><td>Per-identity-provider first broker login and post broker login flow bindings</td></tr><tr><td><code>clientFlowBinding</code></td><td>Per-client browser and direct grant flow overrides</td></tr></tbody></table>
<p>Three properties make this safe:</p>
<p><strong>It's one transaction.</strong> Flows, configs, executions, and every binding are applied inside a single Keycloak transaction. Any failure — a dangling flow reference, a missing identity provider alias, an unknown client — rolls the entire import back. There is no half-applied flow, because there is no intermediate state visible to anyone outside the transaction. The caller needs <code>manage-realm</code>.</p>
<p><strong>Flows are versioned by content, not mutated.</strong> The server hashes the flows and configs in the payload and prefixes every flow alias with that hash. An import never edits a flow that exists; it creates a new, independently-bindable set of flows and then moves the bindings onto them. The flow that was serving traffic a millisecond ago is untouched and still there.</p>
<p><strong>It's idempotent.</strong> Because the aliases are derived from the payload's content hash, re-posting an identical tree is detected and rejected with <code>409 Conflict</code> rather than silently creating a duplicate set of flows. Pass <code>force=true</code> to re-apply the bindings over an already-imported tree. A pipeline can run the import on every deploy without accumulating garbage or needing to diff state first.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="testing-and-rolling-back">Testing and rolling back<a href="https://phasetwo.io/blog/keycloak-atomic-auth-flows/#testing-and-rolling-back" class="hash-link" aria-label="Direct link to Testing and rolling back" title="Direct link to Testing and rolling back" translate="no">​</a></h2>
<p>The content-hash model gives you two things that are genuinely hard with mutable flows.</p>
<p><strong>Canary a flow before it's the realm's flow.</strong> An import can bind its new flow as a <em>per-client</em> browser or direct grant override instead of the realm browser flow. The new flow is live and fully exercisable against one client — a staging app, an internal client, your own test harness — while every other client in the realm keeps authenticating against the current flow. Promote it by importing the same tree with the realm binding.</p>
<p><strong>Roll back by re-binding, not by re-editing.</strong> A previous flow version is still present in the realm under its own hash prefix, with its executions and configs intact. Rolling back is re-posting that previous payload with <code>force=true</code>, which re-applies its bindings in one transaction. It's the same operation as rolling forward, it takes as long as one HTTP request, and it doesn't require reconstructing anything by hand in the admin console at 2am.</p>
<p>The tradeoff is deliberate and worth stating: imports are append-only in spirit, so old flow versions accumulate in the realm. That accumulation <em>is</em> the rollback capability. Pruning old versions is a separate, deliberate act — not something an import does behind your back.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="made-for-configuration-as-code">Made for configuration as code<a href="https://phasetwo.io/blog/keycloak-atomic-auth-flows/#made-for-configuration-as-code" class="hash-link" aria-label="Direct link to Made for configuration as code" title="Direct link to Made for configuration as code" translate="no">​</a></h2>
<p>Once flow updates are a single idempotent request, flows become an ordinary config-as-code artifact. The repository ships a complete worked toolchain in <a href="https://github.com/p2-inc/keycloak-atomic-auth-flows/tree/main/examples" target="_blank" rel="noopener noreferrer" class=""><code>examples/</code></a> that mirrors how this runs in a real deployment:</p>
<ol>
<li class="">
<p><strong>Describe</strong> the flows in a small Ruby DSL (<code>auth_flows_builder.rb</code>) — the hand-edited source of truth, held in git.</p>
</li>
<li class="">
<p><strong>Build</strong> two generated artifacts from it: <code>auth_flows_tree.json</code>, the exact import payload, and <code>auth_flows_tree.txt</code>, a tree rendering that makes flow changes legible in a pull request diff:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">demo-browser/  ← browserFlow</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">├── [ALTERNATIVE] auth-cookie</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">├── [ALTERNATIVE] identity-provider-redirector</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">└── [ALTERNATIVE] demo-browser-forms/  (subflow)</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    ├── [REQUIRED] auth-username-password-form</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    └── [CONDITIONAL] demo-browser-conditional-otp/  (subflow)</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">        ├── [REQUIRED] conditional-user-configured</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">        └── [REQUIRED] auth-otp-form</span><br></div></code></pre></div></div>
</li>
<li class="">
<p><strong>Validate</strong> the payload before it ships. The validator catches dangling subflow, config, and IdP references, orphaned flows, inconsistent executions, and authenticator ids that exceed Keycloak's column length — mistakes that would otherwise only surface at runtime.</p>
</li>
<li class="">
<p><strong>Apply</strong> it with the update script, which posts the payload to the import endpoint. Safe to re-run.</p>
</li>
</ol>
<p>Reviewing an auth flow change becomes reading a diff. Shipping it becomes one request that either fully lands or fully doesn't.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="try-it">Try it<a href="https://phasetwo.io/blog/keycloak-atomic-auth-flows/#try-it" class="hash-link" aria-label="Direct link to Try it" title="Direct link to Try it" translate="no">​</a></h2>
<p>You need Docker and Ruby — no JDK, no Maven, no Keycloak install:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">git clone https://github.com/p2-inc/keycloak-atomic-auth-flows.git</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">cd keycloak-atomic-auth-flows</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"># Keycloak (dev mode) with the extension loaded and a minimal `demo` realm</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">docker compose up --build</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"># In another shell — import the demo flows via the new endpoint</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">ruby examples/scripts/update_auth_flows.rb</span><br></div></code></pre></div></div>
<p>Then open the admin console at <code>http://localhost:8080</code> (admin / admin) → realm <strong>demo</strong> → <em>Authentication</em>, and you'll see the imported flows, alias-prefixed with their import hash, bound to the realm and to the demo identity provider. Run the script a second time to watch the <code>409</code>.</p>
<p>As of publication, the extension targets <strong>Keycloak 26.7.3</strong>, builds with <strong>JDK 21</strong> and Maven, and is licensed <strong>Apache 2.0</strong>. Integration tests run against a real Keycloak via Testcontainers.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="thanks-to-gusto">Thanks to Gusto<a href="https://phasetwo.io/blog/keycloak-atomic-auth-flows/#thanks-to-gusto" class="hash-link" aria-label="Direct link to Thanks to Gusto" title="Direct link to Thanks to Gusto" translate="no">​</a></h2>
<p>This extension exists because Gusto needed to change authentication flows on a schedule their business required, with a risk profile their security posture demanded, and the stock tooling couldn't offer either. They funded and co-designed the work, ran it in production, and agreed to open-source it so the rest of the Keycloak community gets the same guarantees. We're grateful for the partnership.</p>
<hr>
<p>Want to go deeper?</p>
<ol>
<li class="">Read the code and docs: <a href="https://github.com/p2-inc/keycloak-atomic-auth-flows" target="_blank" rel="noopener noreferrer" class="">github.com/p2-inc/keycloak-atomic-auth-flows</a></li>
<li class="">File issues and feature requests on the <a href="https://github.com/p2-inc/keycloak-atomic-auth-flows/issues" target="_blank" rel="noopener noreferrer" class="">issue tracker</a></li>
<li class="">Reach out to our team: <a href="mailto:support@phasetwo.io" target="_blank" rel="noopener noreferrer" class="">support@phasetwo.io</a></li>
<li class="">Talk to us about enterprise Keycloak support: <a href="https://phasetwo.io/contact" target="_blank" rel="noopener noreferrer" class="">phasetwo.io/contact</a></li>
</ol>]]></content:encoded>
            <category>phase_two</category>
            <category>keycloak</category>
            <category>authentication</category>
            <category>authentication_flows</category>
            <category>config_as_code</category>
            <category>open_source</category>
        </item>
        <item>
            <title><![CDATA[Keycloak "Invalid parameter: redirect_uri" — Every Cause and Fix]]></title>
            <link>https://phasetwo.io/blog/keycloak-invalid-redirect-uri/</link>
            <guid>https://phasetwo.io/blog/keycloak-invalid-redirect-uri/</guid>
            <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Why Keycloak rejects your redirect_uri, with a tested matching table for Keycloak 26 — including the query-string rule that breaks most OAuth callbacks.]]></description>
            <content:encoded><![CDATA[<p>You clicked "Log in", Keycloak showed you <strong>"We are sorry… Invalid parameter: redirect_uri"</strong>, and no amount of staring at the admin console explains why.</p>
<p>The short answer: the <code>redirect_uri</code> your application sent does not match, <strong>character for character after wildcard expansion</strong>, any entry in that client's <em>Valid redirect URIs</em>. The long answer is that Keycloak's matching rules are stricter and stranger than almost everyone assumes — in particular, <strong>it will reject any <code>redirect_uri</code> containing a query string, no matter what you registered</strong>.</p>
<p>Everything below was tested against Keycloak 26.7.3.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="first-stop-guessing-read-the-server-log">First: stop guessing, read the server log<a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#first-stop-guessing-read-the-server-log" class="hash-link" aria-label="Direct link to First: stop guessing, read the server log" title="Direct link to First: stop guessing, read the server log" translate="no">​</a></h2>
<p>The browser deliberately shows you nothing useful. Keycloak will not echo the rejected URI back to the page, because doing so would make it an open redirector and a phishing tool.</p>
<p>The server log has no such constraint, and it tells you exactly what was sent:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">WARN [org.keycloak.events] type="LOGIN_ERROR", realmName="demo", clientId="c-multi",</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  error="invalid_redirect_uri", redirect_uri="http://localhost:3000/cb?tenant=acme"</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">docker logs &lt;container&gt; 2&gt;&amp;1 | grep invalid_redirect_uri</span><br></div></code></pre></div></div>
<p>Put the <code>redirect_uri="..."</code> value next to your registered list and the mismatch is usually obvious in about five seconds. <strong>This one step solves most cases</strong>, and almost nobody knows the log line exists.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-matching-rules-tested">The matching rules, tested<a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#the-matching-rules-tested" class="hash-link" aria-label="Direct link to The matching rules, tested" title="Direct link to The matching rules, tested" translate="no">​</a></h2>
<p>Here is what Keycloak 26.7.3 actually does. Every row was run against a live server.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="exact-registration--httplocalhost3000callback">Exact registration — <code>http://localhost:3000/callback</code><a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#exact-registration--httplocalhost3000callback" class="hash-link" aria-label="Direct link to exact-registration--httplocalhost3000callback" title="Direct link to exact-registration--httplocalhost3000callback" translate="no">​</a></h3>
<table><thead><tr><th>Sent <code>redirect_uri</code></th><th>Result</th></tr></thead><tbody><tr><td><code>http://localhost:3000/callback</code></td><td>✅ accepted</td></tr><tr><td><code>http://localhost:3000/callback/</code></td><td>❌ rejected — trailing slash</td></tr><tr><td><code>http://localhost:3000/Callback</code></td><td>❌ rejected — path is case-sensitive</td></tr><tr><td><code>http://localhost:3000/callback?x=1</code></td><td>❌ rejected — query string</td></tr><tr><td><code>http://localhost:3000/callback#frag</code></td><td>❌ rejected</td></tr><tr><td><code>http://localhost:3000/other</code></td><td>❌ rejected</td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="wildcard-registration--httplocalhost3000">Wildcard registration — <code>http://localhost:3000/*</code><a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#wildcard-registration--httplocalhost3000" class="hash-link" aria-label="Direct link to wildcard-registration--httplocalhost3000" title="Direct link to wildcard-registration--httplocalhost3000" translate="no">​</a></h3>
<table><thead><tr><th>Sent <code>redirect_uri</code></th><th>Result</th></tr></thead><tbody><tr><td><code>http://localhost:3000/callback</code></td><td>✅ accepted</td></tr><tr><td><code>http://localhost:3000/</code></td><td>✅ accepted</td></tr><tr><td><code>http://localhost:3000</code></td><td>✅ accepted</td></tr><tr><td><code>http://localhost:3000/a/b/c</code></td><td>✅ accepted — <code>*</code> crosses path segments</td></tr><tr><td><code>http://localhost:3000/cb?code=1</code></td><td>❌ <strong>rejected — query string</strong></td></tr><tr><td><code>https://localhost:3000/callback</code></td><td>❌ rejected — scheme must match</td></tr><tr><td><code>http://localhost:3001/callback</code></td><td>❌ rejected — port must match</td></tr><tr><td><code>http://evil.com/?x=http://localhost:3000/</code></td><td>❌ rejected</td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="trailing-slashes-are-significant-in-both-directions">Trailing slashes are significant in <em>both</em> directions<a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#trailing-slashes-are-significant-in-both-directions" class="hash-link" aria-label="Direct link to trailing-slashes-are-significant-in-both-directions" title="Direct link to trailing-slashes-are-significant-in-both-directions" translate="no">​</a></h3>
<table><thead><tr><th>Registered</th><th>Sent</th><th>Result</th></tr></thead><tbody><tr><td><code>http://localhost:3000</code></td><td><code>http://localhost:3000</code></td><td>✅</td></tr><tr><td><code>http://localhost:3000</code></td><td><code>http://localhost:3000/</code></td><td>❌</td></tr><tr><td><code>http://localhost:3000/</code></td><td><code>http://localhost:3000</code></td><td>❌</td></tr><tr><td><code>http://localhost:3000/</code></td><td><code>http://localhost:3000/</code></td><td>✅</td></tr></tbody></table>
<p><code>http://localhost:3000</code> and <code>http://localhost:3000/</code> are <strong>different registrations</strong>. If you don't know which one your library sends, register both.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="-is-a-substring-wildcard-not-a-path-wildcard"><code>*</code> is a substring wildcard, not a path wildcard<a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#-is-a-substring-wildcard-not-a-path-wildcard" class="hash-link" aria-label="Direct link to -is-a-substring-wildcard-not-a-path-wildcard" title="Direct link to -is-a-substring-wildcard-not-a-path-wildcard" translate="no">​</a></h3>
<p>Registered <code>http://localhost:3000/cb*</code>:</p>
<table><thead><tr><th>Sent</th><th>Result</th></tr></thead><tbody><tr><td><code>http://localhost:3000/cb</code></td><td>✅</td></tr><tr><td><code>http://localhost:3000/cbXYZ</code></td><td>✅ — matches mid-segment</td></tr><tr><td><code>http://localhost:3000/cb/deep</code></td><td>✅ — crosses the <code>/</code> boundary</td></tr><tr><td><code>http://localhost:3000/cb?code=1</code></td><td>❌ — still blocked by the query rule</td></tr></tbody></table>
<p>And registered <code>http://localhost:3000/a/*</code> accepts <code>http://localhost:3000/a</code> — the wildcard is generous at the prefix boundary but stops dead at a <code>?</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-query-string-rule-which-catches-nearly-everyone">The query-string rule, which catches nearly everyone<a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#the-query-string-rule-which-catches-nearly-everyone" class="hash-link" aria-label="Direct link to The query-string rule, which catches nearly everyone" title="Direct link to The query-string rule, which catches nearly everyone" translate="no">​</a></h2>
<p><strong>Keycloak 26.7.3 rejects any <code>redirect_uri</code> containing a <code>?</code>, regardless of the registered pattern.</strong> We tried to register our way around it and could not:</p>
<table><thead><tr><th>Registered</th><th>Sent</th><th>Result</th></tr></thead><tbody><tr><td><code>http://localhost:3000/*</code></td><td><code>http://localhost:3000/cb?tenant=acme</code></td><td>❌</td></tr><tr><td><code>http://localhost:3000/cb*</code></td><td><code>http://localhost:3000/cb?tenant=acme</code></td><td>❌</td></tr><tr><td><code>http://localhost:3000/cb?*</code></td><td><code>http://localhost:3000/cb?tenant=acme</code></td><td>❌</td></tr></tbody></table>
<p>If your app is sending its own query parameters on the callback URL — a tenant hint, a "return to this page" pointer, a feature flag — <strong>that is your bug</strong>, and no redirect URI configuration will fix it.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-to-do-instead">What to do instead<a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#what-to-do-instead" class="hash-link" aria-label="Direct link to What to do instead" title="Direct link to What to do instead" translate="no">​</a></h3>
<p>Use the <code>state</code> parameter. That is what it is for: opaque round-tripped application state, returned to you untouched after login, and it doubles as CSRF protection.</p>
<div class="language-js codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-js codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token comment" style="color:rgb(105, 112, 152);font-style:italic">// ❌ Keycloak will reject this</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">const</span><span class="token plain"> redirectUri </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"https://app.example.com/cb?returnTo=/reports"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token comment" style="color:rgb(105, 112, 152);font-style:italic">// ✅ Same information, correct mechanism</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">const</span><span class="token plain"> redirectUri </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"https://app.example.com/cb"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">const</span><span class="token plain"> state </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(130, 170, 255)">btoa</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token known-class-name class-name" style="color:rgb(255, 203, 107)">JSON</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token method function property-access" style="color:rgb(130, 170, 255)">stringify</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"> </span><span class="token literal-property property">returnTo</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"/reports"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token literal-property property">nonce</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> crypto</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token method function property-access" style="color:rgb(130, 170, 255)">randomUUID</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">;</span><br></div></code></pre></div></div>
<p>Read <code>state</code> back in your callback handler. Every mainstream OIDC library supports this; most will manage <code>state</code> for you if you stop fighting them.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-other-causes-most-common-first">The other causes, most common first<a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#the-other-causes-most-common-first" class="hash-link" aria-label="Direct link to The other causes, most common first" title="Direct link to The other causes, most common first" translate="no">​</a></h2>
<p><strong>1. Trailing slash mismatch.</strong> Your library appends <code>/</code>, your registration doesn't have one. See the table above. Register both forms.</p>
<p><strong>2. Wrong port in development.</strong> Vite on 5173, Next on 3000, your registration says the other. The port is part of the match.</p>
<p><strong>3. <code>http</code> vs <code>https</code>.</strong> Terminating TLS at a proxy and forwarding plain HTTP inside makes your app build an <code>http://</code> callback while you registered <code>https://</code>. Fix the proxy headers (<code>X-Forwarded-Proto</code>) rather than registering the insecure URI.</p>
<p><strong>4. The client isn't the one you think.</strong> Multiple clients, similar names, and you're editing the wrong one — or you're in the wrong realm entirely. The log line names the <code>clientId</code>; check it against what your app sends.</p>
<p><strong>5. Configuration didn't reach the server you're testing.</strong> Realm imported into one instance, browser pointed at another. More common than it sounds with Docker Compose.</p>
<p><strong>6. IdP-initiated or broker flows.</strong> The <code>redirect_uri</code> in play may be constructed by Keycloak's broker rather than your app, and needs registering on the <em>client</em> the broker finally lands on.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="verify-your-fix-without-a-browser">Verify your fix without a browser<a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#verify-your-fix-without-a-browser" class="hash-link" aria-label="Direct link to Verify your fix without a browser" title="Direct link to Verify your fix without a browser" translate="no">​</a></h2>
<p>Ask the authorization endpoint directly:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">CLIENT=demo-app</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">REDIRECT=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  "http://localhost:3000/callback")</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">curl -s "http://localhost:8080/realms/demo/protocol/openid-connect/auth\</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">?client_id=$CLIENT&amp;redirect_uri=$REDIRECT&amp;response_type=code&amp;scope=openid" \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  | grep -q "Invalid parameter" &amp;&amp; echo "REJECTED" || echo "accepted"</span><br></div></code></pre></div></div>
<p>That is exactly how the tables above were produced. It turns a ten-minute click-and-reload loop into a one-second check, and it is trivial to wrap in a loop over every URI your app might send.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="stop-it-recurring-register-uris-as-code">Stop it recurring: register URIs as code<a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#stop-it-recurring-register-uris-as-code" class="hash-link" aria-label="Direct link to Stop it recurring: register URIs as code" title="Direct link to Stop it recurring: register URIs as code" translate="no">​</a></h2>
<p>Redirect URIs drift because they are edited by hand in a console, per environment, by whoever is unblocking themselves that afternoon. Put them in version control instead:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">kcadm.sh update clients/$CLIENT_UUID -r demo \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -s 'redirectUris=["https://app.example.com/callback","https://app.example.com/callback/"]' \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -s 'webOrigins=["https://app.example.com"]'</span><br></div></code></pre></div></div>
<p>Or with the Terraform provider, so every environment is derived from one definition rather than remembered.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="a-word-on-wildcards-and-security">A word on wildcards and security<a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#a-word-on-wildcards-and-security" class="hash-link" aria-label="Direct link to A word on wildcards and security" title="Direct link to A word on wildcards and security" translate="no">​</a></h2>
<p>It is tempting to end the pain with <code>*</code> or <code>https://app.example.com/*</code> and move on. Resist the broad version.</p>
<p>Redirect URI validation is the control that stops an attacker turning your login endpoint into a credential-stealing redirector: they send a victim to a genuine Keycloak login, and the authorization code lands on a host they own. Every wildcard you add widens that.</p>
<p>Practical rules:</p>
<ul>
<li class=""><strong>Never</strong> register a bare <code>*</code>, or a wildcard covering a whole host you don't fully control.</li>
<li class="">Avoid wildcards on anything that serves user-generated content.</li>
<li class="">Prefer a small list of exact URIs. You usually need two or three, not a pattern.</li>
<li class="">Wildcards in development are fine. Wildcards in production want a reason.</li>
</ul>
<p>Keycloak has tightened wildcard handling over successive releases precisely because loose patterns were being exploited. If an upgrade broke a redirect that used to work, that is usually why — and the fix is a narrower URI, not a workaround.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-short-version">The short version<a href="https://phasetwo.io/blog/keycloak-invalid-redirect-uri/#the-short-version" class="hash-link" aria-label="Direct link to The short version" title="Direct link to The short version" translate="no">​</a></h2>
<ol>
<li class="">Read the server log for <code>error="invalid_redirect_uri"</code> — it names the exact URI.</li>
<li class="">Compare it to the registered list, watching for trailing slashes, port, and scheme.</li>
<li class="">If it has a <code>?</code>, that's your problem — move the data into <code>state</code>.</li>
<li class="">Verify with <code>curl</code> against the authorization endpoint, not by clicking.</li>
<li class="">Register the URIs as code so it doesn't drift back.</li>
</ol>
<hr>
<p>Running Keycloak in production means owning these details across every environment and every upgrade. <a class="" href="https://phasetwo.io/hosting/dedicated-clusters/">Phase Two</a> runs managed Keycloak so you don't have to — including the upgrades that change wildcard behaviour. Or work through the <a class="" href="https://phasetwo.io/tutorials/">Keycloak tutorials</a> if you'd rather learn it yourself.</p>]]></content:encoded>
            <category>keycloak</category>
            <category>troubleshooting</category>
            <category>oidc</category>
            <category>clients</category>
            <category>security</category>
        </item>
        <item>
            <title><![CDATA[Keycloak 26.7.3 Released: 20 CVEs, No Fix for 26.4–26.6]]></title>
            <link>https://phasetwo.io/blog/keycloak-2673-released/</link>
            <guid>https://phasetwo.io/blog/keycloak-2673-released/</guid>
            <pubDate>Mon, 31 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Keycloak 26.7.3 fixes 20 CVEs. Not an emergency, but upgrade this week if you use external token exchange or FGAP v2 — and 26.4-26.6 have no patch yet.]]></description>
            <content:encoded><![CDATA[<p><strong>Bottom line: not an emergency, but upgrade this week if you use token exchange with a Google or Microsoft broker, or FGAP v2 to delegate admin rights. On 26.4, 26.5 or 26.6 there is no patch for you today.</strong> Keycloak 26.7.3 fixes 20 CVEs — one high, 17 medium, one low, one unrated — plus two breaking changes. Every one needs a delegated admin account, client credentials, or an intercepted authorization code; the single high is in a test-only dependency.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="should-you-upgrade">Should you upgrade?<a href="https://phasetwo.io/blog/keycloak-2673-released/#should-you-upgrade" class="hash-link" aria-label="Direct link to Should you upgrade?" title="Direct link to Should you upgrade?" translate="no">​</a></h2>
<table><thead><tr><th>If your realms…</th><th>When</th></tr></thead><tbody><tr><td>Exchange Google or Microsoft tokens via token exchange</td><td><strong>This week.</strong> Domain and tenant limits went unenforced.</td></tr><tr><td>Delegate admin rights with FGAP v2</td><td><strong>This week.</strong> Six fixes; sub-admins could act outside scope.</td></tr><tr><td>Run on 26.4, 26.5 or 26.6</td><td><strong>Plan the move to 26.7</strong> — no backport is tagged.</td></tr><tr><td>Use redirect URIs with <code>state</code>, <code>code</code> or <code>session_state</code></td><td><strong>Test first</strong> — now rejected by default.</td></tr><tr><td>None of the above</td><td>Next normal cycle.</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="security-fixes">Security fixes<a href="https://phasetwo.io/blog/keycloak-2673-released/#security-fixes" class="hash-link" aria-label="Direct link to Security fixes" title="Direct link to Security fixes" translate="no">​</a></h2>
<table><thead><tr><th>CVE / advisory</th><th>Severity</th><th>What it is</th></tr></thead><tbody><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-35563" target="_blank" rel="noopener noreferrer" class="">CVE-2026-35563</a> / <a href="https://github.com/advisories/GHSA-85rw-g4f4-jprr" target="_blank" rel="noopener noreferrer" class="">GHSA-85rw-g4f4-jprr</a></td><td>high</td><td>LDAP client skips TLS hostname check</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-18215" target="_blank" rel="noopener noreferrer" class="">CVE-2026-18215</a> / <a href="https://github.com/advisories/GHSA-v8h5-7wp9-qxxv" target="_blank" rel="noopener noreferrer" class="">GHSA-v8h5-7wp9-qxxv</a></td><td>medium 6.8</td><td>Token exchange ignores Microsoft tenant limit</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-18214" target="_blank" rel="noopener noreferrer" class="">CVE-2026-18214</a> / <a href="https://github.com/advisories/GHSA-wmhp-w67v-6jm5" target="_blank" rel="noopener noreferrer" class="">GHSA-wmhp-w67v-6jm5</a></td><td>medium 6.8</td><td>Token exchange ignores Google domain limit</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-18571" target="_blank" rel="noopener noreferrer" class="">CVE-2026-18571</a> / <a href="https://github.com/advisories/GHSA-5vmc-qhfj-qxc3" target="_blank" rel="noopener noreferrer" class="">GHSA-5vmc-qhfj-qxc3</a></td><td>medium 6.6</td><td>User creation adds unpermitted groups</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-17059" target="_blank" rel="noopener noreferrer" class="">CVE-2026-17059</a> / <a href="https://github.com/advisories/GHSA-4w3x-69m8-478c" target="_blank" rel="noopener noreferrer" class="">GHSA-4w3x-69m8-478c</a></td><td>medium 6.5</td><td>Role-members endpoint leaks user PII</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-18572" target="_blank" rel="noopener noreferrer" class="">CVE-2026-18572</a> / <a href="https://github.com/advisories/GHSA-mcjq-c4g7-wcfh" target="_blank" rel="noopener noreferrer" class="">GHSA-mcjq-c4g7-wcfh</a></td><td>medium 6.5</td><td>Claim token overrides time-policy clock</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-18573" target="_blank" rel="noopener noreferrer" class="">CVE-2026-18573</a> / <a href="https://github.com/advisories/GHSA-wm3j-jpqg-fwv2" target="_blank" rel="noopener noreferrer" class="">GHSA-wm3j-jpqg-fwv2</a></td><td>medium 6.5</td><td>Client update dodges access-type policy</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-79652" target="_blank" rel="noopener noreferrer" class="">CVE-2026-79652</a> / <a href="https://github.com/advisories/GHSA-9f9p-c2v5-98rx" target="_blank" rel="noopener noreferrer" class="">GHSA-9f9p-c2v5-98rx</a></td><td>medium 5.9</td><td>JWT-bearer grant skips consent</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-18201" target="_blank" rel="noopener noreferrer" class="">CVE-2026-18201</a> / <a href="https://github.com/advisories/GHSA-fvjx-r757-3r6r" target="_blank" rel="noopener noreferrer" class="">GHSA-fvjx-r757-3r6r</a></td><td>medium 5.5</td><td>IdP creation binds broker to organization</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-16093" target="_blank" rel="noopener noreferrer" class="">CVE-2026-16093</a> / <a href="https://github.com/advisories/GHSA-jmhg-9c54-p575" target="_blank" rel="noopener noreferrer" class="">GHSA-jmhg-9c54-p575</a></td><td>medium 5.4</td><td>Unsigned header bypasses signed-JWT policy</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-16089" target="_blank" rel="noopener noreferrer" class="">CVE-2026-16089</a> / <a href="https://github.com/advisories/GHSA-63wm-fvw8-h2hp" target="_blank" rel="noopener noreferrer" class="">GHSA-63wm-fvw8-h2hp</a></td><td>medium 5.4</td><td>Auth code redeemable by another client</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-18570" target="_blank" rel="noopener noreferrer" class="">CVE-2026-18570</a> / <a href="https://github.com/advisories/GHSA-r6f5-hj4x-7mq7" target="_blank" rel="noopener noreferrer" class="">GHSA-r6f5-hj4x-7mq7</a></td><td>medium 5.4</td><td>Omitted field bypasses full-scope policy</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-16072" target="_blank" rel="noopener noreferrer" class="">CVE-2026-16072</a> / <a href="https://github.com/advisories/GHSA-mp76-m6c2-jqh5" target="_blank" rel="noopener noreferrer" class="">GHSA-mp76-m6c2-jqh5</a></td><td>medium 4.9</td><td>Org manager creates members via invite</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-16105" target="_blank" rel="noopener noreferrer" class="">CVE-2026-16105</a> / <a href="https://github.com/advisories/GHSA-w32v-46r7-99r7" target="_blank" rel="noopener noreferrer" class="">GHSA-w32v-46r7-99r7</a></td><td>medium 4.9</td><td>Composite-role endpoints skip authorization</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-16106" target="_blank" rel="noopener noreferrer" class="">CVE-2026-16106</a> / <a href="https://github.com/advisories/GHSA-78mv-phq4-25fv" target="_blank" rel="noopener noreferrer" class="">GHSA-78mv-phq4-25fv</a></td><td>medium 4.9</td><td>Delegated admin removes privileged roles</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-16108" target="_blank" rel="noopener noreferrer" class="">CVE-2026-16108</a> / <a href="https://github.com/advisories/GHSA-mvwh-6438-3v8w" target="_blank" rel="noopener noreferrer" class="">GHSA-mvwh-6438-3v8w</a></td><td>medium 4.3</td><td>Default-group reads disclose hidden groups</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-16104" target="_blank" rel="noopener noreferrer" class="">CVE-2026-16104</a> / <a href="https://github.com/advisories/GHSA-qh48-wwv4-fmr8" target="_blank" rel="noopener noreferrer" class="">GHSA-qh48-wwv4-fmr8</a></td><td>medium 4.3</td><td>View-only admin sees reCAPTCHA secrets</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-18218" target="_blank" rel="noopener noreferrer" class="">CVE-2026-18218</a> / <a href="https://github.com/advisories/GHSA-vhxw-j6h3-48jm" target="_blank" rel="noopener noreferrer" class="">GHSA-vhxw-j6h3-48jm</a></td><td>medium 4.2</td><td>Client not-before revocation ignored</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-18209" target="_blank" rel="noopener noreferrer" class="">CVE-2026-18209</a> / <a href="https://github.com/advisories/GHSA-c8xx-fr3x-6m5w" target="_blank" rel="noopener noreferrer" class="">GHSA-c8xx-fr3x-6m5w</a></td><td>low 3.4</td><td>Redirect-URI injection via URL fragment</td></tr><tr><td><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-19729" target="_blank" rel="noopener noreferrer" class="">CVE-2026-19729</a> / none</td><td><strong>unrated</strong></td><td>Path-traversal probing (incomplete 9083 fix)</td></tr></tbody></table>
<p>Counted from the release notes: 20 security bullets, 20 CVE ids.</p>
<p><strong>CVE-2026-18214 and CVE-2026-18215 (medium, 6.8)</strong> are the reason to move. If you restricted a Google broker to a Workspace domain or a Microsoft broker to one tenant, token exchange ignored that: a valid token from any other domain bought a Keycloak token.</p>
<p><strong>CVE-2026-35563 (high, 8.8) does not reach a running server.</strong> <a href="https://github.com/keycloak/keycloak/issues/50785" target="_blank" rel="noopener noreferrer" class="">The upstream issue</a> calls it "present in Keycloak's development dependencies", the fix was an ApacheDS test-server upgrade, and <code>org.apache.directory.api</code> appears only under <code>util/embedded-ldap/</code> and <code>testsuite/</code>. LDAP federation uses JNDI.</p>
<p><strong>CVE-2026-19729 has no advisory, so it carries no severity.</strong> Keycloak calls it an incomplete fix for <a href="https://nvd.nist.gov/vuln/detail/CVE-2026-9083" target="_blank" rel="noopener noreferrer" class="">CVE-2026-9083</a> (<a href="https://github.com/advisories/GHSA-7pm9-g8jh-3m74" target="_blank" rel="noopener noreferrer" class="">GHSA-7pm9-g8jh-3m74</a>) — filesystem probing by a <code>manage-realm</code> admin. Weigh it from <a href="https://nvd.nist.gov/vuln/detail/CVE-2026-19729" target="_blank" rel="noopener noreferrer" class="">NVD</a>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="if-you-run-264-265-or-266">If you run 26.4, 26.5 or 26.6<a href="https://phasetwo.io/blog/keycloak-2673-released/#if-you-run-264-265-or-266" class="hash-link" aria-label="Direct link to If you run 26.4, 26.5 or 26.6" title="Direct link to If you run 26.4, 26.5 or 26.6" translate="no">​</a></h2>
<p>Every advisory here lists affected and patched versions as "Unknown". The upstream issues do better — each carries <code>release/</code> labels naming the branches its fix is headed for. Checked 2 September:</p>
<table><thead><tr><th>Branch</th><th>Newest release</th><th>Newest tag</th><th>These fixes</th></tr></thead><tbody><tr><td>26.7</td><td>26.7.3</td><td>26.7.3</td><td>all 20</td></tr><tr><td>26.6</td><td>26.6.4</td><td>26.6.6 (11 Aug)</td><td>all 20 labelled <code>26.6.7</code> — <strong>not tagged yet</strong></td></tr><tr><td>26.5</td><td>26.5.7</td><td>26.5.7</td><td>none labelled</td></tr><tr><td>26.4</td><td>26.4.7</td><td>26.4.15 (11 Aug)</td><td>CVE-2026-19729 only, labelled <code>26.4.16</code> — <strong>not tagged yet</strong></td></tr></tbody></table>
<p>Keycloak tags backports without publishing a release, so 26.6.6 and 26.4.15 are real despite nothing announcing them, and we publish <a class="" href="https://phasetwo.io/extensions/containers/">container images</a> for both. Both predate this work. If a row above applies, the answer is 26.7.3.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="breaking-changes">Breaking changes<a href="https://phasetwo.io/blog/keycloak-2673-released/#breaking-changes" class="hash-link" aria-label="Direct link to Breaking changes" title="Direct link to Breaking changes" translate="no">​</a></h2>
<p>Two, both in the <a href="https://www.keycloak.org/docs/latest/upgrading/#migration-changes" target="_blank" rel="noopener noreferrer" class="">upgrading guide</a>:</p>
<ul>
<li class=""><strong>OIDC parameters in redirect URIs are rejected by default</strong> — those containing <code>state</code>, <code>code</code> or <code>session_state</code> now fail. This is the hardening behind CVE-2026-18209. Escape hatches exist (<code>allow-oidc-params-in-redirect-uris</code>, per-client <code>allow.oidc.params.in.redirect.uris</code>) but are deprecated and go in Keycloak 27.</li>
<li class=""><strong>Authorization services claim handling</strong> — the <code>kc.</code> prefix is reserved, and user claims using it are filtered before policy evaluation. On a collision, permission-ticket claims beat <code>claim_token</code> claims.</li>
</ul>
<p>Also deprecated: <code>inviteLink</code> is no longer returned by the organization invitation endpoints — the fix for CVE-2026-16072.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-else-changed">What else changed<a href="https://phasetwo.io/blog/keycloak-2673-released/#what-else-changed" class="hash-link" aria-label="Direct link to What else changed" title="Direct link to What else changed" translate="no">​</a></h2>
<p>Three of 19 bug fixes matter at many-realm scale: admin API cost growing super-linearly with realm count since 26.7.1 (#51554), lightweight access tokens resolving every role in every realm per admin request (#51707), and sustained high CPU on all nodes (#51523). If your cluster got hot after 26.7.1, this is it — the shape of problem behind our <a class="" href="https://phasetwo.io/blog/scaling-keycloak-event-storage/">event-storage work</a>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="how-to-upgrade">How to upgrade<a href="https://phasetwo.io/blog/keycloak-2673-released/#how-to-upgrade" class="hash-link" aria-label="Direct link to How to upgrade" title="Direct link to How to upgrade" translate="no">​</a></h2>
<p>Read the <a href="https://www.keycloak.org/docs/latest/upgrading/#migration-changes" target="_blank" rel="noopener noreferrer" class="">migration changes</a> first; the <a href="https://www.keycloak.org/docs/latest/release_notes/index.html" target="_blank" rel="noopener noreferrer" class="">release notes</a> stop at 26.7.0. Test the redirect-URI change on a staging realm — the only thing here that breaks a working login. Our <a class="" href="https://phasetwo.io/docs/security/">security docs</a> cover controls several of these CVEs bypassed; <a class="" href="https://phasetwo.io/blog/orgs-scim-experimental/">Organizations</a> users, note the invitation change.</p>
<p>Rather not schedule it? Our <a class="" href="https://phasetwo.io/hosting/dedicated-clusters/">managed Keycloak</a> clusters are patched in our maintenance windows, under SOC 2 Type II and ISO 27001. <a class="" href="https://phasetwo.io/contact/">Talk to us</a>.</p>]]></content:encoded>
            <category>keycloak</category>
            <category>release</category>
            <category>security</category>
            <category>upgrades</category>
        </item>
        <item>
            <title><![CDATA[How We Scaled Keycloak Event Storage with Logs, S3, and ClickHouse]]></title>
            <link>https://phasetwo.io/blog/scaling-keycloak-event-storage/</link>
            <guid>https://phasetwo.io/blog/scaling-keycloak-event-storage/</guid>
            <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[How Phase Two moved Keycloak event storage out of the database and into a log-based analytics pipeline — with an open-source EventStoreProvider anyone can use.]]></description>
            <content:encoded><![CDATA[<p>Every login, logout, failed password attempt, and admin change in Keycloak produces an event. That's exactly what you want for security auditing and product analytics — until you realize where Keycloak puts them: <strong>in the same relational database that your authentication path depends on</strong>. At scale, event storage becomes a problem you can't ignore. Here's how we solved it, and how the key piece — an MDC-logging <code>EventStoreProvider</code> — is open source so you can solve it too.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-problem-your-auth-database-is-not-an-analytics-database">The Problem: Your Auth Database Is Not an Analytics Database<a href="https://phasetwo.io/blog/scaling-keycloak-event-storage/#the-problem-your-auth-database-is-not-an-analytics-database" class="hash-link" aria-label="Direct link to The Problem: Your Auth Database Is Not an Analytics Database" title="Direct link to The Problem: Your Auth Database Is Not an Analytics Database" translate="no">​</a></h2>
<p>Out of the box, Keycloak stores events in the <code>EVENT_ENTITY</code> and <code>ADMIN_EVENT_ENTITY</code> tables via its JPA event store. This works fine for a small realm. It works a lot less fine when you're hosting many busy realms:</p>
<ul>
<li class=""><strong>Event writes ride the request transaction.</strong> The JPA event store persists events inside the same transaction as the request that produced them. Every login is now gated on an extra insert: if the database is slow, the request thread waits; if event persistence fails, it can take the whole request down with it. Your users are paying an event-storage tax on every authentication, and your busiest login day is also your busiest event-write day.</li>
<li class=""><strong>Big tables are operationally fragile tables.</strong> Once <code>EVENT_ENTITY</code> grows into the tens or hundreds of millions of rows, routine maintenance becomes dangerous. A Keycloak upgrade that touches the event schema can hold a lock on a table that every in-flight request is trying to insert into — and now request threads are hanging behind a migration. The same goes for adding an index or any other DDL you'd normally consider harmless.</li>
<li class=""><strong>Expiry is a bulk <code>DELETE</code> against your hot path.</strong> Keycloak's event expiry doesn't make old events quietly disappear — it runs mass deletes against the very tables your logins are writing to, generating lock contention, vacuum/bloat pressure, and I/O spikes. So you're stuck choosing between unbounded growth and periodic self-inflicted incidents, and either way you lose the long history your customers actually want.</li>
<li class=""><strong>Queries that hurt.</strong> "Show me all failed logins for this user over the last 90 days" is an analytics query: a full scan over a huge, write-hot, row-oriented table. Running analytics queries against your production auth database during business hours is how you end up on a status page.</li>
</ul>
<p>Our customers were asking good questions — <em>How many active users did we have last month? When did failed logins spike? Who changed that client configuration?</em> — and the honest answer was that the default event store isn't built to answer them at scale.</p>
<p>We wanted three things at once:</p>
<ol>
<li class="">Keep the auth database small, fast, and boring.</li>
<li class="">Keep events <strong>forever</strong> (or at least long enough for real analytics and audit).</li>
<li class="">Make months of events queryable in milliseconds, with dashboards on top.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-insight-events-are-logs">The Insight: Events Are Logs<a href="https://phasetwo.io/blog/scaling-keycloak-event-storage/#the-insight-events-are-logs" class="hash-link" aria-label="Direct link to The Insight: Events Are Logs" title="Direct link to The Insight: Events Are Logs" translate="no">​</a></h2>
<p>An authentication event is immutable, timestamped, and append-only. That's not a database row — that's a <strong>log line</strong>. And modern infrastructure is extremely good at moving, storing, and analyzing log lines cheaply.</p>
<p>So instead of treating the database as the system of record for events, we restructured the whole thing as a pipeline:</p>
<!-- -->
<p>Each stage does one job, and each stage is independently scalable. Let's walk through them.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-1-an-open-source-eventstoreprovider-that-writes-events-as-structured-logs">Step 1: An Open-Source EventStoreProvider That Writes Events as Structured Logs<a href="https://phasetwo.io/blog/scaling-keycloak-event-storage/#step-1-an-open-source-eventstoreprovider-that-writes-events-as-structured-logs" class="hash-link" aria-label="Direct link to Step 1: An Open-Source EventStoreProvider That Writes Events as Structured Logs" title="Direct link to Step 1: An Open-Source EventStoreProvider That Writes Events as Structured Logs" translate="no">​</a></h2>
<p>The foundation lives in our open-source <a href="https://github.com/p2-inc/keycloak-events" target="_blank" rel="noopener noreferrer" class="">keycloak-events</a> extension (also on <a href="https://central.sonatype.com/artifact/io.phasetwo.keycloak/keycloak-events" target="_blank" rel="noopener noreferrer" class="">Maven Central</a>). We added a new implementation of Keycloak's <code>EventStoreProvider</code> SPI: the <strong>MDC Logger Event Store</strong> (<code>ext-event-mdc-logger-store</code>).</p>
<p>Instead of (or in addition to) writing events to JPA, it flattens each user and admin event and emits it as a structured log line, with every field carried in the <a href="https://logging.apache.org/log4j/2.x/manual/thread-context.html" target="_blank" rel="noopener noreferrer" class="">MDC</a> (Mapped Diagnostic Context). When Keycloak logs to JSON on stdout — standard practice in containers — every event comes out as a machine-parseable record:</p>
<div class="language-json codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-json codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"timestamp"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"2026-06-22T14:03:11.402Z"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"loggerName"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"io.phasetwo.keycloak.EVENT_LOGGER"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"message"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"Event Logger"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"mdc"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"event.class"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"USER"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"event.id"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"a1b2c3d4-..."</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"event.type"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"LOGIN"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"event.realmName"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"acme"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"event.clientId"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"web-app"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"event.userId"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"8f14e45f-..."</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"event.ipAddress"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"203.0.113.7"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"event.time"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"1750601391402"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"event.detailsJson"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"{\"auth_method\":\"openid-connect\", ...}"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></div></code></pre></div></div>
<p>A few design decisions turned out to matter a lot:</p>
<p><strong>Dual write is a config flag, not a fork.</strong> The provider can wrap Keycloak's built-in JPA event store, so you can run both stores side by side while you build out your pipeline. Events land in the database (so the Admin Console and Events REST API keep working exactly as before) <em>and</em> in your logs. Once your downstream pipeline is trustworthy, flip <code>use-jpa</code> off and your database stops accumulating events entirely:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">export EXT_EVENT_MDC_LOGGER_ENABLED=true</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">kc.sh build</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">kc.sh start \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  --spi-events-store-provider=ext-event-mdc-logger-store \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  --spi-events-store-ext-event-mdc-logger-store-use-jpa=true</span><br></div></code></pre></div></div>
<p><strong>Events are emitted post-commit.</strong> Events are queued in the Keycloak transaction and only logged after it commits. A rolled-back request never produces a phantom event, so your analytics never disagree with reality.</p>
<p><strong>MDC context is scoped, not leaked.</strong> Keycloak reuses executor threads, and MDC state is thread-local. The provider sets the <code>event.*</code> MDC keys inside a try-with-resources scope and restores the previous values immediately after the log call, so event fields never bleed into unrelated log lines on the same thread.</p>
<p><strong>Separate loggers for user and admin events.</strong> User events go to <code>io.phasetwo.keycloak.EVENT_LOGGER</code> and admin events to <code>io.phasetwo.keycloak.ADMIN_EVENT_LOGGER</code>, which makes downstream routing and filtering trivial — you match on the logger name, not on fragile message parsing.</p>
<p><strong>Graceful degradation.</strong> When JPA is disabled, the provider returns empty query results rather than throwing, so the Admin Console's Events tab degrades quietly instead of erroring.</p>
<p>This provider is <a href="https://github.com/p2-inc/keycloak-events/blob/main/LICENSE" target="_blank" rel="noopener noreferrer" class="">Elastic License 2.0</a> like the rest of the keycloak-events extension — free to use in your own deployments. Everything downstream of it is just standard logging infrastructure, which is exactly the point: <strong>once your events are structured log lines, the entire observability ecosystem becomes your event store.</strong></p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-2-filter-and-ship--fluent-bit-loki-and-s3">Step 2: Filter and Ship — Fluent Bit, Loki, and S3<a href="https://phasetwo.io/blog/scaling-keycloak-event-storage/#step-2-filter-and-ship--fluent-bit-loki-and-s3" class="hash-link" aria-label="Direct link to Step 2: Filter and Ship — Fluent Bit, Loki, and S3" title="Direct link to Step 2: Filter and Ship — Fluent Bit, Loki, and S3" translate="no">​</a></h2>
<p>On every node in our clusters, <a href="https://fluentbit.io/" target="_blank" rel="noopener noreferrer" class="">Fluent Bit</a> tails the Keycloak container logs and does the routing:</p>
<ul>
<li class=""><strong>Full Keycloak logs</strong> go to <a href="https://grafana.com/oss/loki/" target="_blank" rel="noopener noreferrer" class="">Loki</a> (itself backed by S3, with a 90-day retention policy) for operational debugging and ad-hoc log queries.</li>
<li class=""><strong>Event lines only</strong> — matched by those two dedicated logger names — get a much more interesting treatment. Fluent Bit strips each record down to just the <code>mdc</code> payload, <strong>redacts PII fields</strong> like IP addresses and usernames before they ever leave the node, and writes the results as JSON-lines files to a dedicated S3 bucket, partitioned by cluster and date.</li>
</ul>
<p>That filtering step is what keeps the pipeline lean. Loki holds everything for the ops team; the events bucket holds exactly one clean, structured record per event and nothing else. S3 becomes the durable, dirt-cheap, infinitely-retained system of record — and because it's just JSON in a bucket, we're never locked into any particular downstream consumer.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-3-clickhouse-ingestion-with-s3queue">Step 3: ClickHouse Ingestion with S3Queue<a href="https://phasetwo.io/blog/scaling-keycloak-event-storage/#step-3-clickhouse-ingestion-with-s3queue" class="hash-link" aria-label="Direct link to Step 3: ClickHouse Ingestion with S3Queue" title="Direct link to Step 3: ClickHouse Ingestion with S3Queue" translate="no">​</a></h2>
<p>For the analytics layer we chose <a href="https://clickhouse.com/" target="_blank" rel="noopener noreferrer" class="">ClickHouse</a>, running alongside each cluster. The ingestion mechanism is one of ClickHouse's best-kept secrets: the <a href="https://clickhouse.com/docs/en/engines/table-engines/integrations/s3queue" target="_blank" rel="noopener noreferrer" class=""><code>S3Queue</code> table engine</a>, which continuously watches an S3 prefix and streams new files in as they appear:</p>
<div class="language-sql codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-sql codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token keyword" style="font-style:italic">CREATE</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">TABLE</span><span class="token plain"> keycloak_events</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">s3_keycloak_event_queue </span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">raw String</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">ENGINE</span><span class="token plain"> </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> S3Queue</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token string" style="color:rgb(195, 232, 141)">'https://&lt;bucket&gt;.s3.amazonaws.com/*/*/*'</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> JSONAsString</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">SETTINGS </span><span class="token keyword" style="font-style:italic">mode</span><span class="token plain"> </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">'unordered'</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">         keeper_path </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">'/clickhouse/s3queue/keycloak-events'</span><span class="token punctuation" style="color:rgb(199, 146, 234)">;</span><br></div></code></pre></div></div>
<p>No Kafka, no scheduled batch jobs, no ingestion service to babysit. ClickHouse's Keeper tracks which S3 objects have been processed, giving us reliable exactly-once-style ingestion with zero moving parts beyond ClickHouse itself.</p>
<p>From the queue, a chain of materialized views does the shaping:</p>
<ol>
<li class="">A <strong>raw landing table</strong> (MergeTree, partitioned by month) captures every line with its source file metadata and the extracted cluster, realm, and event class.</li>
<li class=""><strong>Typed tables</strong> for user and admin events extract each <code>event.*</code> field into a proper column — <code>event_type</code>, <code>user_id</code>, <code>client_id</code>, <code>ip_address</code>, <code>operation_type</code>, <code>resource_path</code>, and so on. These use <code>ReplacingMergeTree</code> keyed on ingest time, so if a file is ever re-delivered after a crash, duplicates collapse away at merge time.</li>
<li class=""><strong>Five-minute rollup tables</strong> (<code>SummingMergeTree</code>) pre-aggregate event counts by realm, event type, and error. Five minutes divides evenly into every interval our dashboards offer (15 minutes, 1 hour, 1 day), so a single rollup grain serves every zoom level.</li>
</ol>
<p>One honest lesson from production: we initially experimented with S3-backed MergeTree storage for the ClickHouse tables themselves, and walked it back. Every part write, merge, and TTL operation turned into S3 <code>PutObject</code> churn — on the order of 150 requests per second per cluster — and the API request costs showed up on the bill fast. Local NVMe-backed volumes for the tables, with S3 as the ingestion source and archive, turned out to be the right split.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-4-a-lambda-gateway-to-expose-queries-as-web-services">Step 4: A Lambda Gateway to Expose Queries as Web Services<a href="https://phasetwo.io/blog/scaling-keycloak-event-storage/#step-4-a-lambda-gateway-to-expose-queries-as-web-services" class="hash-link" aria-label="Direct link to Step 4: A Lambda Gateway to Expose Queries as Web Services" title="Direct link to Step 4: A Lambda Gateway to Expose Queries as Web Services" translate="no">​</a></h2>
<p>ClickHouse lives on a private network, and we didn't want dashboards talking SQL to it directly. So we put a small <strong>AWS Lambda behind an HTTP API Gateway</strong> in front of it, exposing a handful of purpose-built REST endpoints:</p>
<ul>
<li class=""><code>/insights/user-events</code> and <code>/insights/admin-events</code> — filtered, paginated event search (by realm, event type, user, client, IP, time range, plus full-text search over event details)</li>
<li class=""><code>/insights/metrics</code> — named, pre-defined aggregations: successful and failed logins, registrations, password resets, MFA enrollment, lockouts, DAU/WAU/MAU, and a session-concurrency estimate</li>
</ul>
<p>The security model is deliberately narrow. API Gateway validates a <strong>JWT</strong> on every request (issued, naturally, by Keycloak). The Lambda then checks a cluster claim in the token against the cluster being queried, so a tenant can only ever see their own events. And there is no free-form SQL anywhere in the API — every endpoint maps to a parameterized query using ClickHouse's native <code>{param:Type}</code> placeholders, which makes injection structurally impossible rather than merely filtered.</p>
<p>The metrics endpoints read from the rollup tables and re-bucket the five-minute grain to whatever interval the caller asks for, so even "show me a year of logins by day" comes back in tens of milliseconds.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-5-dashboards-people-actually-use">Step 5: Dashboards People Actually Use<a href="https://phasetwo.io/blog/scaling-keycloak-event-storage/#step-5-dashboards-people-actually-use" class="hash-link" aria-label="Direct link to Step 5: Dashboards People Actually Use" title="Direct link to Step 5: Dashboards People Actually Use" translate="no">​</a></h2>
<p>The final layer is the UI in the Phase Two dashboard: an events section per cluster with two complementary views.</p>
<p><strong>Metrics</strong> shows stacked charts of user events by type and admin events by resource and operation, plus a grid of the questions customers actually ask: successful vs. failed logins over time, daily active users, DAU/WAU/MAU tiles, new registrations, password resets, MFA enrollments and removals, login error breakdowns, and session concurrency. Drag-select on any chart zooms every chart on the page to the same window.</p>
<p><strong>Event search</strong> is a virtualized table over the raw events with the full filter set — realm, event type, user, client, IP, time range, free-text search over event details — and a detail drawer showing the complete event payload with copy-to-clipboard JSON.</p>
<p>The little UX decision we're proudest of is the <strong>drill-down</strong>: click any segment of a stacked bar — say, the <code>LOGIN_ERROR</code> slice at 3 AM on Tuesday — and you land in the event search view, pre-filtered to that event type and that time bucket. Aggregate to instance in one click. That interaction is only cheap because the layers below it are: the chart is a rollup-table query and the drill-down is a typed-table query, both answered by the same gateway in milliseconds.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-we-got">What We Got<a href="https://phasetwo.io/blog/scaling-keycloak-event-storage/#what-we-got" class="hash-link" aria-label="Direct link to What We Got" title="Direct link to What We Got" translate="no">​</a></h2>
<p>The scoreboard after moving event storage out of the database:</p>
<ul>
<li class=""><strong>The auth database does auth.</strong> Event writes, event expiry deletes, and analytics queries are gone from the hot path entirely.</li>
<li class=""><strong>Retention is no longer a trade-off.</strong> S3 keeps the raw events indefinitely for peanuts; ClickHouse keeps them queryable.</li>
<li class=""><strong>Analytics got fast.</strong> Questions that were previously unaskable — a year of login trends across a busy realm — return interactively.</li>
<li class=""><strong>Every layer is replaceable.</strong> Because the contract between Keycloak and everything else is just structured JSON log lines, you could swap Loki for Elasticsearch, ClickHouse for BigQuery, or our dashboard for Grafana, and the provider wouldn't know or care.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="build-it-yourself">Build It Yourself<a href="https://phasetwo.io/blog/scaling-keycloak-event-storage/#build-it-yourself" class="hash-link" aria-label="Direct link to Build It Yourself" title="Direct link to Build It Yourself" translate="no">​</a></h2>
<p>The piece that makes all of this possible — the MDC Logger <code>EventStoreProvider</code> — is open source in <a href="https://github.com/p2-inc/keycloak-events" target="_blank" rel="noopener noreferrer" class="">p2-inc/keycloak-events</a>, alongside our webhook and scripting event listeners. If you run your own Keycloak, you can drop in the extension, enable the provider, and point whatever log pipeline you already have at those two logger names. The <a href="https://github.com/p2-inc/keycloak-events#mdc-logger-event-store" target="_blank" rel="noopener noreferrer" class="">README</a> covers configuration in detail, and we'd love to hear what you build on top of it.</p>
<p>And if you'd rather not run any of this yourself — the provider, the pipeline, the ClickHouse cluster, the dashboards — that's quite literally what we do. Every Phase Two hosted deployment gets this event analytics stack out of the box, along with a team that has already found the sharp edges for you.</p>
<hr>
<p><strong>Want to talk Keycloak event analytics, or scaling Keycloak in general?</strong></p>
<ul>
<li class="">📩 <a href="mailto:sales@phasetwo.io" target="_blank" rel="noopener noreferrer" class="">Reach out to the team →</a></li>
<li class="">👉 <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Get Started with Free Managed Keycloak</a></li>
</ul>]]></content:encoded>
            <category>phase_two</category>
            <category>keycloak</category>
            <category>events</category>
            <category>open_source</category>
            <category>hosting</category>
            <category>data</category>
        </item>
        <item>
            <title><![CDATA[Introducing the Starter Cluster Tier — Phase Two Keycloak from $149/month]]></title>
            <link>https://phasetwo.io/blog/starter-tier-launch/</link>
            <guid>https://phasetwo.io/blog/starter-tier-launch/</guid>
            <pubDate>Tue, 30 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A new dedicated-cluster tier for teams moving from evaluation to a production-ready Phase Two Keycloak deployment. $149/month, 30-day free trial, all the features of our dedicated clusters. Also covering the wind-down of our free shared-realm offering.]]></description>
            <content:encoded><![CDATA[<p>Today we're launching a new way to run Phase Two: the <strong>Starter</strong> cluster tier, available at <strong>$149 per month</strong>. Starter bundles the same feature set as our existing dedicated clusters into a plan that's accessible for teams getting their use case off the ground or running development environments with lower SLA needs. It also includes a <strong>30-day free trial</strong>, so you can validate your setup before committing.</p>
<p><a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Log into the dashboard</a> and click <strong>Create Cluster</strong> to pick the new tier and provision yours.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-you-get">What you get<a href="https://phasetwo.io/blog/starter-tier-launch/#what-you-get" class="hash-link" aria-label="Direct link to What you get" title="Direct link to What you get" translate="no">​</a></h2>
<p>A Starter cluster is a real dedicated Phase Two Keycloak cluster — not a shared realm carved out of someone else's resources. That means:</p>
<ul>
<li class=""><strong>Your own dedicated compute, network, and storage.</strong> No noisy neighbors, no shared connection limits, no contention for cache.</li>
<li class=""><strong>The full Phase Two enhanced Keycloak distribution</strong> — <a href="https://phasetwo.io/product/organizations/" target="_blank" rel="noopener noreferrer" class="">Organizations</a>, <a href="https://phasetwo.io/product/magic-link/" target="_blank" rel="noopener noreferrer" class="">Magic Links</a>, <a href="https://phasetwo.io/product/webhooks/" target="_blank" rel="noopener noreferrer" class="">Webhooks</a>, the <a href="https://phasetwo.io/product/sso/#idp-wizard" target="_blank" rel="noopener noreferrer" class="">IdP Wizard</a>, and the rest of our <a href="https://github.com/p2-inc" target="_blank" rel="noopener noreferrer" class="">open-source extension library</a> bundled in.</li>
<li class=""><strong>Custom domains</strong> (CNAME) for your login portals.</li>
<li class=""><strong>Cluster observability</strong> — uptime, logs, and usage metrics surfaced through the <a class="" href="https://phasetwo.io/blog/dashboard-launch/">new dashboard</a>.</li>
<li class=""><strong>The same managed CockroachDB tier</strong> powering our larger plans, in partnership with <a href="https://www.cockroachlabs.com/" target="_blank" rel="noopener noreferrer" class="">Cockroach Labs</a>.</li>
<li class=""><strong>All the regions we support</strong> — pick whichever is closest to your users.</li>
</ul>
<p>Starter is built to be genuinely capable, with resource limits set where most early-stage teams actually need them — enough headroom for real production workloads and development environments. When you need more, our existing higher-capacity cluster offerings are ready for you. They've proven themselves capable for many teams pushing serious throughput, with a larger resource envelope and a matching SLA — and you can scale straight up from Starter into them without re-provisioning.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="who-its-for">Who it's for<a href="https://phasetwo.io/blog/starter-tier-launch/#who-its-for" class="hash-link" aria-label="Direct link to Who it's for" title="Direct link to Who it's for" translate="no">​</a></h2>
<p>We built Starter for two specific shapes of team:</p>
<ol>
<li class=""><strong>Teams ready to move past evaluation.</strong> You've validated Phase Two on a free realm, you're getting close to a real launch, and you want to put your auth on its own infrastructure with a predictable monthly price.</li>
<li class=""><strong>Dev and staging environments.</strong> You're already running a dedicated cluster in production and want a matching environment that doesn't double your bill. Starter is intentionally priced so a team can comfortably run a cluster-per-environment without thinking twice.</li>
</ol>
<p>If your use case is large or mission-critical from day one, our <a class="" href="https://phasetwo.io/pricing/">higher-capacity clusters</a> remain the right choice. Starter is the on-ramp, not the destination — and clusters scale up from Starter into the larger tiers without a re-provision.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="try-it-for-30-days-free">Try it for 30 days, free<a href="https://phasetwo.io/blog/starter-tier-launch/#try-it-for-30-days-free" class="hash-link" aria-label="Direct link to Try it for 30 days, free" title="Direct link to Try it for 30 days, free" translate="no">​</a></h2>
<p>Every new Starter cluster includes a <strong>30-day free trial</strong>. That's enough time to wire your application up against a real cluster, run integration tests, validate SSO connections, and decide whether it fits the way you work. If you're still validating at day 30, talk to us — we'd rather give you the runway than rush you.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="how-to-provision-one">How to provision one<a href="https://phasetwo.io/blog/starter-tier-launch/#how-to-provision-one" class="hash-link" aria-label="Direct link to How to provision one" title="Direct link to How to provision one" translate="no">​</a></h2>
<ol>
<li class=""><a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Open the dashboard</a>.</li>
<li class="">Click <strong>Create Cluster</strong>.</li>
<li class="">Pick <strong>Starter</strong> as the tier.</li>
<li class="">Choose your region.</li>
<li class="">Watch it provision (most clusters are live within 30 minutes).</li>
</ol>
<p>Detailed setup walkthroughs live in the <a class="" href="https://phasetwo.io/docs/self-service/dedicated-clusters/">Cluster docs</a>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-this-means-for-our-free-shared-realms">What this means for our free shared realms<a href="https://phasetwo.io/blog/starter-tier-launch/#what-this-means-for-our-free-shared-realms" class="hash-link" aria-label="Direct link to What this means for our free shared realms" title="Direct link to What this means for our free shared realms" translate="no">​</a></h2>
<p>Alongside the Starter launch, <strong>we are discontinuing our free, shared-realm offering</strong>. Free realms have served us well — they've onboarded thousands of teams over the years and given us a clear picture of how people validate Phase Two — but they've also become harder to operate sustainably as our paying-customer footprint has grown.</p>
<p>The Starter tier (with its 30-day trial) is now the path from "I want to try this" to "I'm running it in production." We think it's a clearer journey, and it gives us a single class of infrastructure we can invest in deeply rather than splitting effort between shared and dedicated systems.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="migration-timeline">Migration timeline<a href="https://phasetwo.io/blog/starter-tier-launch/#migration-timeline" class="hash-link" aria-label="Direct link to Migration timeline" title="Direct link to Migration timeline" translate="no">​</a></h3>
<p>If you currently have active realms on our free, shared infrastructure:</p>
<ul>
<li class=""><strong>You have 30 days from today (2026-06-30) — through 2026-07-30 — to upgrade and migrate your realms</strong> if you want to preserve them.</li>
<li class="">After 2026-07-30, the shared infrastructure will be shut down and its data permanently deleted.</li>
</ul>
<p>To migrate, create a Starter (or larger) cluster from the dashboard, export your realm config from the shared instance, and import it into your new cluster. Keycloak's <a href="https://www.keycloak.org/server/importExport" target="_blank" rel="noopener noreferrer" class="">realm import/export guide</a> describes the underlying mechanics, and our team is happy to walk through the migration with you.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="were-here-to-help">We're here to help<a href="https://phasetwo.io/blog/starter-tier-launch/#were-here-to-help" class="hash-link" aria-label="Direct link to We're here to help" title="Direct link to We're here to help" translate="no">​</a></h2>
<ul>
<li class=""><strong>Technical or migration questions</strong> — email <a href="mailto:support@phasetwo.io" target="_blank" rel="noopener noreferrer" class="">support@phasetwo.io</a>.</li>
<li class=""><strong>Pricing, upgrade paths, or help picking the right tier</strong> — email <a href="mailto:sales@phasetwo.io" target="_blank" rel="noopener noreferrer" class="">sales@phasetwo.io</a>.</li>
</ul>
<p>Whichever way you contact us, the actual humans who built and operate the infrastructure are the ones writing back.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="thank-you">Thank you<a href="https://phasetwo.io/blog/starter-tier-launch/#thank-you" class="hash-link" aria-label="Direct link to Thank you" title="Direct link to Thank you" translate="no">​</a></h2>
<p>If you've used Phase Two — free shared realm or dedicated cluster — you're part of the reason we're able to launch this tier today. We're grateful you picked us for your Keycloak hosting, and we're going to keep working to make the platform more reliable, more affordable, and more useful for what you're building.</p>
<p>If we've helped, we'd love a <a href="https://github.com/p2-inc" target="_blank" rel="noopener noreferrer" class="">⭐ on GitHub</a> or a follow on <a href="https://www.linkedin.com/company/phase-two-tech" target="_blank" rel="noopener noreferrer" class="">LinkedIn</a>. It genuinely matters.</p>
<p>— The Phase Two Team</p>]]></content:encoded>
            <category>release</category>
            <category>starter</category>
            <category>pricing</category>
            <category>keycloak</category>
            <category>phase_two</category>
            <category>dedicated</category>
        </item>
        <item>
            <title><![CDATA[Observability for Keycloak, with Zero Setup]]></title>
            <link>https://phasetwo.io/blog/observability-launch/</link>
            <guid>https://phasetwo.io/blog/observability-launch/</guid>
            <pubDate>Mon, 22 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Phase Two Observability is here. View requests, event data, and live logs for your dedicated Keycloak cluster with zero setup — built right into the dashboard.]]></description>
            <content:encoded><![CDATA[<p>Today we're launching <strong>Observability</strong> for dedicated Keycloak clusters — built directly into the Phase Two dashboard with <strong>zero setup</strong>. Requests, event data, and live logs are all there the moment your cluster is running. No agents to install, no log shippers to configure, no Prometheus, Grafana, or Loki stack to stand up and maintain.</p>
<p>This is the feature we're most excited about this year, because of what it changes about getting Keycloak into production. Standing up the observability layer around a Keycloak cluster — metrics pipelines, log aggregation, dashboards, alerting — is the kind of work that normally takes a devops team <strong>weeks to months</strong>. With Phase Two, it's already done. Getting up and running on a fully observable Keycloak cluster is now a matter of <strong>minutes</strong>.</p>
<div class="theme-admonition theme-admonition-info admonition_Y66Y alert alert--info"><div class="admonitionHeading_Feou"><span class="admonitionIcon_RBEH"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>info</div><div class="admonitionContent_oUgd"><p><strong>Available for Dedicated Clusters</strong>
Observability is included with all Phase Two <strong>dedicated cluster</strong> plans. Open the <strong>Metrics</strong> or <strong>Logs</strong> section on your cluster in the <a href="https://dash.phasetwo.io/clusters" target="_blank" rel="noopener noreferrer" class="">self-service dashboard</a> to get started.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="see-your-whole-system-at-a-glance">See your whole system at a glance<a href="https://phasetwo.io/blog/observability-launch/#see-your-whole-system-at-a-glance" class="hash-link" aria-label="Direct link to See your whole system at a glance" title="Direct link to See your whole system at a glance" translate="no">​</a></h2>
<p>Start with the big picture: <em>understanding what your cluster is doing</em>, at a glance, without building a single chart yourself.</p>
<p><strong>Event Metrics</strong> chart Keycloak authentication and admin activity over any time range: user events by type, successful and failed logins, active users per day, new registrations, password resets, MFA enrollment, and login-error reasons.</p>
<figure><img src="https://phasetwo.io/docs/observability/user-events.png" alt="Event Metrics — user events by type charted over time"><figcaption>Event Metrics: authentication and admin activity, derived from Keycloak events.</figcaption></figure>
<p><strong>Request Metrics</strong> chart the HTTP traffic and performance of the cluster: requests by endpoint type and status class, error rate, cache hit ratio, latency and TTFB percentiles (p50 / p95 / p99), bytes served, top endpoints, and top user agents.</p>
<figure><img src="https://phasetwo.io/docs/observability/requests.png" alt="Request Metrics — requests by endpoint type charted over time"><figcaption>Request Metrics: HTTP traffic, errors, caching, and latency for the cluster.</figcaption></figure>
<p>Spot an anomaly in a chart, then drop straight into the logs to find the exact requests and events behind it. The metrics tell you <em>something changed</em>; the logs tell you <em>exactly what</em>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="debugging-that-takes-minutes-not-days">Debugging that takes minutes, not days<a href="https://phasetwo.io/blog/observability-launch/#debugging-that-takes-minutes-not-days" class="hash-link" aria-label="Direct link to Debugging that takes minutes, not days" title="Direct link to Debugging that takes minutes, not days" translate="no">​</a></h2>
<p>When something goes wrong with an authentication flow — a login that fails intermittently, a theme that won't load, a token error that only some users hit — the slowest part has always been <em>seeing what actually happened</em>. Traditionally that meant SSH-ing into nodes, grepping through container logs, or waiting for someone with cluster access to pull the right files.</p>
<p>Phase Two Observability collapses that loop. Open the <strong>Logs</strong> section, filter to the realm and time window, and watch the relevant lines stream in live.</p>
<figure><img src="https://phasetwo.io/docs/dashboard/cluster-observability-stream-logs-2.png" alt="Phase Two Dash — live Cluster Logs streaming with filters for time range, realm, level, and search"><figcaption>Stream and inspect cluster logs live — filter by time range, realm, and level, then click any line for the full structured payload.</figcaption></figure>
<p>The streaming view gives you everything you need to chase down an issue without leaving the browser:</p>
<ul>
<li class=""><strong>Live streaming</strong> with auto-refresh, so new lines appear as they happen</li>
<li class=""><strong>Realm and level filters</strong> (<code>INFO</code>, <code>WARN</code>, <code>ERROR</code>, <code>DEBUG</code>, <code>TRACE</code>, <code>FATAL</code>)</li>
<li class=""><strong>LogQL-style search</strong> — <code>|=</code> to match, <code>!=</code> to exclude, <code>|~</code> for regex. For example, <code>|= "LOGIN_ERROR" != "admin-cli"</code> finds login failures while filtering out a known source, and <code>|= "NullPointerException"</code> surfaces Java exception lines instantly.</li>
<li class=""><strong>Click into any line</strong> to open the full payload, logger name, and stack trace</li>
</ul>
<p>What used to be a multi-hour, multi-person investigation becomes a few clicks.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="whats-included">What's included<a href="https://phasetwo.io/blog/observability-launch/#whats-included" class="hash-link" aria-label="Direct link to What's included" title="Direct link to What's included" translate="no">​</a></h2>
<p>Observability is split into two sections in the dashboard, both fully time-range and realm filterable, with all timestamps in <strong>UTC</strong>:</p>
<ul>
<li class=""><strong>Metrics</strong> — <a href="https://phasetwo.io/docs/self-service/metrics/#event-metrics" target="_blank" rel="noopener noreferrer" class="">Event Metrics</a> and <a href="https://phasetwo.io/docs/self-service/metrics/#request-metrics" target="_blank" rel="noopener noreferrer" class="">Request Metrics</a>: aggregated, charted views of authentication activity and HTTP traffic.</li>
<li class=""><strong>Logs</strong> — <a href="https://phasetwo.io/docs/self-service/logs/#cluster-logs" target="_blank" rel="noopener noreferrer" class="">Cluster Logs</a> (live streaming), <a href="https://phasetwo.io/docs/self-service/logs/#event-logs" target="_blank" rel="noopener noreferrer" class="">Event Logs</a>, <a href="https://phasetwo.io/docs/self-service/logs/#request-logs" target="_blank" rel="noopener noreferrer" class="">Request Logs</a>, and <a href="https://phasetwo.io/docs/self-service/logs/#download-logs" target="_blank" rel="noopener noreferrer" class="">Download Logs</a> for retained files you can pull for offline review, auditing, or support cases.</li>
</ul>
<p>This is the full realization of the observability work we <a href="https://phasetwo.io/blog/cluster-observability-and-logs/" target="_blank" rel="noopener noreferrer" class="">previewed back in February</a>, which started with log downloads. Live streaming, structured event and request views, and the metrics charts are all here now.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="why-this-matters">Why this matters<a href="https://phasetwo.io/blog/observability-launch/#why-this-matters" class="hash-link" aria-label="Direct link to Why this matters" title="Direct link to Why this matters" translate="no">​</a></h2>
<p>Running Keycloak yourself means running everything around it too — and the observability stack is one of the heaviest parts of that "everything." Phase Two gives you that layer for free, built in, from the moment your cluster comes up. Your team spends its time shipping authentication features and resolving issues, not building and babysitting monitoring infrastructure.</p>
<p>Zero setup. Requests, events, and logs in one place. Debugging in minutes. A production-ready, observable Keycloak cluster in the time it takes to read this post.</p>
<hr>
<p>Ready to try it? Log in to the <a href="https://dash.phasetwo.io/clusters" target="_blank" rel="noopener noreferrer" class="">Phase Two Dash</a> and open the <strong>Metrics</strong> and <strong>Logs</strong> sections on your cluster. Learn more in the <a href="https://phasetwo.io/docs/self-service/observability/" target="_blank" rel="noopener noreferrer" class="">Observability documentation</a>. Questions? Reach us at <a href="mailto:support@phasetwo.io" target="_blank" rel="noopener noreferrer" class="">support@phasetwo.io</a>.</p>]]></content:encoded>
            <category>phase_two</category>
            <category>hosting</category>
            <category>self-service</category>
            <category>observability</category>
            <category>metrics</category>
            <category>logs</category>
            <category>keycloak</category>
            <category>dedicated-clusters</category>
        </item>
        <item>
            <title><![CDATA[Migrating from WorkOS to Keycloak: A Practical Walkthrough]]></title>
            <link>https://phasetwo.io/blog/workos-keycloak-migration/</link>
            <guid>https://phasetwo.io/blog/workos-keycloak-migration/</guid>
            <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[An open-source tool that imports users, organizations, roles, SSO connections, and SCIM directories from WorkOS into Phase Two-enabled Keycloak — plus the why, the how, and what to do once it's done.]]></description>
            <content:encoded><![CDATA[<p>A few quarters ago you got handed a single-line ask: <strong>"we need enterprise SSO and directory sync by the end of the quarter."</strong> Maybe the deal was a Fortune-500 logo. Maybe it was a Series B requirement. Either way you found WorkOS, wired in their SDK in a long weekend, shipped the deal, and got the high five.</p>
<p>Then the renewal came in. The seat-based pricing, that sounded harmless when you had two customers using SSO, looks different when you have forty. Suddenly there's a line item on a board slide that scales linearly with your enterprise revenue — a parasite that eats into the very margin that the enterprise tier was supposed to fund. The CFO walks over and asks you to "fix it."</p>
<p>Here is the awkward truth nobody tells the engineer-on-the-spot: <strong>the WorkOS feature set has had a fully open-source equivalent for years</strong>. Keycloak handles SSO. Phase Two's <a href="https://phasetwo.io/product/organizations/" target="_blank" rel="noopener noreferrer" class="">organizations</a> extension handles multi-tenant orgs. The <a href="https://phasetwo.io/product/sso/#idp-wizard" target="_blank" rel="noopener noreferrer" class="">identity provider wizard</a> handles the same admin-portal flow your customers see in WorkOS today. The catch is that nobody wanted to spend the runway to migrate.</p>
<p>We've now built the tool that turns that "we'll deal with it later" debt into an afternoon of work. Why? Because WorkOS customers are starting to wake up to Keycloak, and they're coming to us in droves.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-we-built">What we built<a href="https://phasetwo.io/blog/workos-keycloak-migration/#what-we-built" class="hash-link" aria-label="Direct link to What we built" title="Direct link to What we built" translate="no">​</a></h2>
<p>The <a href="https://github.com/p2-inc/workos-keycloak-migrator" target="_blank" rel="noopener noreferrer" class=""><code>workos-keycloak-migrator</code></a> is a small multi-module Maven project that does three things in lockstep:</p>
<!-- -->
<table><thead><tr><th>Component</th><th>What it does</th></tr></thead><tbody><tr><td><strong>Bulk migrator CLI</strong></td><td>A standalone <code>java -jar</code> that reads everything WorkOS exposes via API and writes it into Keycloak + Phase Two. Idempotent — re-run it whenever you want a fresh reconciliation.</td></tr><tr><td><strong>Webhook listener extension</strong></td><td>A Keycloak <code>RealmResourceProvider</code> that subscribes to WorkOS webhooks and applies live updates while the two systems run in parallel.</td></tr><tr><td><strong>Slow-migration extension</strong></td><td>Bridges <a href="https://github.com/daniel-frak/keycloak-user-migration" target="_blank" rel="noopener noreferrer" class="">keycloak-user-migration</a> to WorkOS so users who haven't been touched in a while can still log in with their WorkOS password the first time and get materialised into Keycloak on demand.</td></tr></tbody></table>
<p>Every entity we touch carries a <code>workos.id</code> attribute so the migrator can recognise it on every subsequent run — no duplicates, no drift.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="how-to-use-it">How to use it<a href="https://phasetwo.io/blog/workos-keycloak-migration/#how-to-use-it" class="hash-link" aria-label="Direct link to How to use it" title="Direct link to How to use it" translate="no">​</a></h2>
<p>We deliberately kept the runbook short. Here are the six steps:</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="1-build-the-artifacts">1. Build the artifacts<a href="https://phasetwo.io/blog/workos-keycloak-migration/#1-build-the-artifacts" class="hash-link" aria-label="Direct link to 1. Build the artifacts" title="Direct link to 1. Build the artifacts" translate="no">​</a></h3>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">mvn -DskipTests package</span><br></div></code></pre></div></div>
<p>This produces three jars:</p>
<ul>
<li class=""><code>migrator/target/workos-keycloak-migrator.jar</code> — the bulk runner.</li>
<li class=""><code>extensions/webhook-listener/target/workos-webhook-listener.jar</code> — drop into Keycloak's <code>providers/</code> directory.</li>
<li class=""><code>extensions/slow-migration/target/workos-slow-migration.jar</code> — same place.</li>
</ul>
<p>The build is plain Maven so it slots into whatever CI you already use.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="2-stand-up-a-phase-two-enabled-keycloak">2. Stand up a Phase Two-enabled Keycloak<a href="https://phasetwo.io/blog/workos-keycloak-migration/#2-stand-up-a-phase-two-enabled-keycloak" class="hash-link" aria-label="Direct link to 2. Stand up a Phase Two-enabled Keycloak" title="Direct link to 2. Stand up a Phase Two-enabled Keycloak" translate="no">​</a></h3>
<p>If you already run Phase Two's <a href="https://phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">hosted offering</a> or your own Phase Two containers, point at that. If you're trying this locally first, the repo ships a <code>docker-compose.yml</code> that brings up Postgres plus the <a href="https://quay.io/repository/phasetwo/phasetwo-keycloak" target="_blank" rel="noopener noreferrer" class="">phasetwo-keycloak image</a> with both extension jars mounted:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">docker compose up -d</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="3-bootstrap-the-realm">3. Bootstrap the realm<a href="https://phasetwo.io/blog/workos-keycloak-migration/#3-bootstrap-the-realm" class="hash-link" aria-label="Direct link to 3. Bootstrap the realm" title="Direct link to 3. Bootstrap the realm" translate="no">​</a></h3>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">./scripts/bootstrap-realm.sh</span><br></div></code></pre></div></div>
<p>This creates the <code>migrate-target</code> realm, flips a couple of Keycloak defaults that would otherwise eat our attributes (<code>unmanagedAttributePolicy=ENABLED</code> and <code>sslRequired=NONE</code> for local HTTP), and creates a <code>migrator-cli</code> service-account client with <code>realm-admin</code>. The script prints the client secret you need for step 4.</p>
<p><strong>Why a service-account client?</strong> The bulk migrator authenticates against Keycloak using OAuth client-credentials. Putting it in its own client (instead of, say, reusing the <code>admin</code> master user) keeps the credential surface area small and auditable.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="4-run-the-bulk-migrator">4. Run the bulk migrator<a href="https://phasetwo.io/blog/workos-keycloak-migration/#4-run-the-bulk-migrator" class="hash-link" aria-label="Direct link to 4. Run the bulk migrator" title="Direct link to 4. Run the bulk migrator" translate="no">​</a></h3>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">java -jar migrator/target/workos-keycloak-migrator.jar \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  --workos-api-key=$WORKOS_API_KEY \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  --keycloak-url=https://your-keycloak.example.com \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  --keycloak-realm=migrate-target \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  --keycloak-client-id=migrator-cli \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  --keycloak-client-secret=$KC_CLIENT_SECRET \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  --source-label=production</span><br></div></code></pre></div></div>
<p>The runner walks WorkOS in dependency order — environment roles first, then organizations, then per-org roles, then identity-provider stubs, then SCIM stubs, then users, then memberships, then directory users — and prints a summary at the end:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">============= Migration summary =============</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">role:                    {CREATED=5}</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">organization:            {CREATED=7}</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">organization_role:       {CREATED=38}</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">identity_provider:       {PARTIAL=2}</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">user:                    {CREATED=10}</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">organization_membership: {CREATED=10}</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">Total failed: 0</span><br></div></code></pre></div></div>
<p>Re-running picks up where the last run left off (cursors are persisted on the realm), and reports <code>SKIPPED reason=unchanged</code> for anything that hasn't drifted since the last sync. This is what lets you keep both systems in parallel — run the migrator daily in a cron job and the WorkOS state shows up in Keycloak each morning.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="5-optional-enable-the-extensions">5. (Optional) Enable the extensions<a href="https://phasetwo.io/blog/workos-keycloak-migration/#5-optional-enable-the-extensions" class="hash-link" aria-label="Direct link to 5. (Optional) Enable the extensions" title="Direct link to 5. (Optional) Enable the extensions" translate="no">​</a></h3>
<p>Both Keycloak extensions are <strong>opt-in per realm</strong> because we don't want a one-shot <code>docker compose up</code> to fan out across every realm in a shared cluster. Set:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">KC_SPI_REALM_RESTAPI_EXTENSION_WORKOS_WEBHOOK_REALMS=migrate-target</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">KC_SPI_REALM_RESTAPI_EXTENSION_WORKOS_LEGACY_REALMS=migrate-target</span><br></div></code></pre></div></div>
<p>(or <code>*</code> for "every realm") and restart Keycloak. The webhook listener will auto-register a WorkOS webhook endpoint on first boot; the slow-migration extension will install the federation component that asks our resource for users it doesn't recognise.</p>
<p>The webhook listener requires an HTTPS URL because WorkOS won't accept anything else for delivery. If you only have HTTP locally, the listener still serves traffic — it just skips the auto-provisioning step and waits for you to register an HTTPS endpoint yourself.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="6-re-run-for-reconciliation-whenever-you-like">6. Re-run for reconciliation whenever you like<a href="https://phasetwo.io/blog/workos-keycloak-migration/#6-re-run-for-reconciliation-whenever-you-like" class="hash-link" aria-label="Direct link to 6. Re-run for reconciliation whenever you like" title="Direct link to 6. Re-run for reconciliation whenever you like" translate="no">​</a></h3>
<p>The bulk migrator is idempotent and cheap to re-run. Many teams run it nightly during the parallel-operation window, then switch off WorkOS once they're happy with the diff.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-the-migrator-cannot-import-and-why">What the migrator cannot import (and why)<a href="https://phasetwo.io/blog/workos-keycloak-migration/#what-the-migrator-cannot-import-and-why" class="hash-link" aria-label="Direct link to What the migrator cannot import (and why)" title="Direct link to What the migrator cannot import (and why)" translate="no">​</a></h2>
<p>There are two pieces of WorkOS state that we cannot pull through the API:</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="sso-connection-secrets-and-metadata">SSO connection secrets and metadata.<a href="https://phasetwo.io/blog/workos-keycloak-migration/#sso-connection-secrets-and-metadata" class="hash-link" aria-label="Direct link to SSO connection secrets and metadata." title="Direct link to SSO connection secrets and metadata." translate="no">​</a></h3>
<p>The WorkOS <code>/connections</code> endpoint exposes the connection's <em>type</em> (Okta SAML, Azure SAML, Google OIDC, etc.) and the organization it's attached to, but <strong>not</strong> the SAML metadata URL, the OIDC client_id/secret, or any of the SSO/SLO URLs. Those were entered into WorkOS through their admin portal by the customer's IT team and are not retrievable.</p>
<p>The migrator handles this honestly: it creates a Keycloak identity provider for every connection with the right <code>providerId</code> (<code>saml</code>, <code>oidc</code>, <code>google</code>, <code>microsoft</code>, etc.), copies the SAML signing certificate when WorkOS happens to expose it, and tags every record with <code>workos.incomplete=true</code> and <code>workos.connection_id=&lt;the original id&gt;</code>. You end up with a placeholder you can finish in two clicks via Phase Two's <a href="https://phasetwo.io/extensions/idp-wizard/" target="_blank" rel="noopener noreferrer" class="">IdP Wizard</a> — or even better, by sending a <a href="https://phasetwo.io/extensions/admin-portal/" target="_blank" rel="noopener noreferrer" class="">portal link</a> to the customer's IT contact and letting them re-walk the same setup flow they already know.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="scim-directory-connection-auth">SCIM directory connection auth<a href="https://phasetwo.io/blog/workos-keycloak-migration/#scim-directory-connection-auth" class="hash-link" aria-label="Direct link to SCIM directory connection auth" title="Direct link to SCIM directory connection auth" translate="no">​</a></h3>
<p>Same shape: <code>/directories</code> tells you the directory exists, what provider (Okta SCIM, generic SCIM 2.0, Azure SCIM, etc.) and which organization it belongs to — but the bearer token your customer's IdP uses to push users is opaque to the WorkOS API. The migrator creates a Phase Two SCIM provider on the right org with a placeholder secret and the same <code>workos.directory.incomplete=true</code> tag so it stands out in the admin UI.</p>
<p>In both cases the customer's IT team has to re-establish the secret. The good news: they only have to do it once, and Phase Two's admin portal flow is the same one they already used for WorkOS, just with a different brand on it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-happens-after-the-migrator-runs">What happens after the migrator runs<a href="https://phasetwo.io/blog/workos-keycloak-migration/#what-happens-after-the-migrator-runs" class="hash-link" aria-label="Direct link to What happens after the migrator runs" title="Direct link to What happens after the migrator runs" translate="no">​</a></h2>
<p>Three things you'll want to do in the days following the migration:</p>
<p><strong>1. Walk the <code>workos.incomplete=true</code> records.</strong> Filter your Phase Two organizations by that attribute and you'll see exactly which connections need attention. Send a portal link to each affected customer's IT contact. They click through the same identity-provider wizard their team used in WorkOS; ten minutes later the IdP is live in Keycloak and the <code>workos.incomplete</code> tag goes away on the next reconciliation run.</p>
<p><strong>2. Audit the <code>scim-managed</code> realm role.</strong> Every user that originated from a WorkOS Directory Sync gets the <code>scim-managed</code> realm role plus <code>scim.directory_id</code> / <code>scim.directory_user_id</code> attributes. That's your "do not deprovision" list — anyone with that role is being managed by upstream SCIM, so a manual admin deletion is going to get reversed. The role gives you an easy filter in the admin UI and a stable bit of state you can hook into your own provisioning logic.</p>
<p><strong>3. Install the slow-migration extension (if you haven't already) before pointing your login UI at Keycloak.</strong> Without it, users whose passwords never made it through the migration (WorkOS doesn't let us export them — they're hashed, scoped to WorkOS's authentication endpoint) will fail their first login. With it, their first login gets verified against WorkOS by our extension, then their password hash is captured and stored in Keycloak. After that they look identical to a native Keycloak user.</p>
<p>Once those three things are done you can flip your AuthKit-style flows to Keycloak, point your application at the new realm, and start the WorkOS cancellation timer.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-bigger-picture">The bigger picture<a href="https://phasetwo.io/blog/workos-keycloak-migration/#the-bigger-picture" class="hash-link" aria-label="Direct link to The bigger picture" title="Direct link to The bigger picture" translate="no">​</a></h2>
<p>If you've made it this far you're probably nodding along — the technical migration is straightforward, the gotchas are small, and the math on the new cost line is much better. But it's worth saying out loud what the rest of the move looks like.</p>
<p>WorkOS's pitch was always "we make enterprise auth easy for SaaS developers." The pitch worked because, until recently, the open-source alternative had no admin portal, no IdP wizard, no SCIM Box, no domain verification flow — only the raw Keycloak primitives. To replicate WorkOS's developer experience you had to build the whole admin surface yourself.</p>
<p><a href="https://phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Phase Two has built that admin surface.</a> The <a href="https://phasetwo.io/extensions/organizations/" target="_blank" rel="noopener noreferrer" class="">organizations</a> extension gives you multi-tenant orgs with role-based assignments. The <a href="https://phasetwo.io/extensions/idp-wizard/" target="_blank" rel="noopener noreferrer" class="">IdP Wizard</a> replaces the WorkOS connection setup flow. Phase Two SCIM gives you the same managed directory-sync experience. The <a href="https://phasetwo.io/extensions/admin-portal" target="_blank" rel="noopener noreferrer" class="">portal link</a> experience lets you delegate SSO/SCIM setup to your customer's IT team without giving them admin access to your Keycloak — exactly the same delegation model WorkOS uses, with the same UX, just running on Keycloak you control.</p>
<p>The crucial difference, and the reason the migration is worth doing: <a href="https://phasetwo.io/pricing/hosting/" target="_blank" rel="noopener noreferrer" class=""><strong>Phase Two's pricing doesn't grow with your enterprise revenue.</strong></a> SSO, SCIM, organizations, branding, the wizard — they're all part of the platform, not metered features. Your cost stays a predictable budget line item whether you have ten enterprise customers or ten thousand.</p>
<p>The migrator we just walked through is, in that sense, the smallest part of the story. It's the bridge that lets a team that originally chose WorkOS for time-to-market cross over to a platform that doesn't tax that decision forever.</p>
<p>If you've got a WorkOS migration on your roadmap and would like a hand, <a href="https://phasetwo.io/contact/" target="_blank" rel="noopener noreferrer" class="">reach out</a> — we've now done this end-to-end on a real WorkOS Sandbox tenant and the tooling is open source. We'd be happy to walk you through it.</p>]]></content:encoded>
            <category>phase_two</category>
            <category>keycloak</category>
            <category>workos</category>
            <category>migration</category>
            <category>sso</category>
            <category>organizations</category>
            <category>scim</category>
            <category>open_source</category>
        </item>
        <item>
            <title><![CDATA[Experimental SCIM 2.0 provisioning for Organizations]]></title>
            <link>https://phasetwo.io/blog/orgs-scim-experimental/</link>
            <guid>https://phasetwo.io/blog/orgs-scim-experimental/</guid>
            <pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Per-organization SCIM 2.0 endpoints for Keycloak, built on Metatavu's keycloak-scim-server. Experimental, but already in production use.]]></description>
            <content:encoded><![CDATA[<p>We're shipping experimental SCIM 2.0 provisioning for the Phase Two <a href="https://github.com/p2-inc/keycloak-orgs" target="_blank" rel="noopener noreferrer" class="">Organizations extension</a>. Each organization in a realm can now act as its own SCIM 2.0 service provider, so an upstream IdP like Okta or Entra ID can push users into a specific tenant rather than into the realm as a whole.</p>
<p>This is the piece of the multi-tenant story that Keycloak's stock SCIM support doesn't address today, and it's been a heavily requested item from customers running Organizations in production.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="how-this-differs-from-keycloaks-experimental-scim">How this differs from Keycloak's experimental SCIM<a href="https://phasetwo.io/blog/orgs-scim-experimental/#how-this-differs-from-keycloaks-experimental-scim" class="hash-link" aria-label="Direct link to How this differs from Keycloak's experimental SCIM" title="Direct link to How this differs from Keycloak's experimental SCIM" translate="no">​</a></h2>
<p>Keycloak shipped its own experimental SCIM 2.0 capability in version <code>26.6</code>, and it's worth being clear about how the two relate, because the names overlap but the scopes don't:</p>
<ul>
<li class=""><strong>Keycloak's SCIM is realm-level.</strong> An external IdP provisions users into the realm. There is one SCIM endpoint per realm and one population of users. That model is fine when a realm represents a single customer or a single workforce — it's the classic Keycloak deployment shape.</li>
<li class=""><strong>Our SCIM is organization-level.</strong> An external IdP provisions users into a specific organization within a realm. There is one SCIM endpoint <em>per organization</em>, and each one has its own authentication configuration, its own optional IdP federation link, and its own member graph. The realm can host thousands of organizations, each backed by a different upstream identity system, without those populations bleeding into each other.</li>
</ul>
<p>If you're running a B2B SaaS where each customer is an Organization, the realm-level model isn't what you want — you'd be asking every customer's IT department to provision into a shared pool. The per-organization model is the natural fit: each customer's IdP gets its own SCIM URL, its own credentials, and provisions only into its own tenant.</p>
<p>Both are experimental. They're not in competition — they're solving different problems at different layers — and over time we expect the realm-level and organization-level capabilities to coexist in the same Keycloak deployment.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="whats-in-the-release">What's in the release<a href="https://phasetwo.io/blog/orgs-scim-experimental/#whats-in-the-release" class="hash-link" aria-label="Direct link to What's in the release" title="Direct link to What's in the release" translate="no">​</a></h2>
<p><img decoding="async" loading="lazy" alt="Organization SCIM Admin UI" src="https://phasetwo.io/assets/images/2026-05-27-orgs-scim-ui-4e424e712500eada55888947fdbe413f.png" width="2996" height="1694" class="img_R7yg"></p>
<p>The capability ships disabled by default and is gated by a realm-level flag. Once enabled, each organization gets:</p>
<ul>
<li class="">A SCIM 2.0 endpoint at <code>/realms/{realm}/scim/v2/organizations/{orgId}/</code> exposing the standard SCIM resources (<code>/ServiceProviderConfig</code>, <code>/ResourceTypes</code>, <code>/Schemas</code>, <code>/Users</code>, <code>/Groups</code>).</li>
<li class="">A <strong>SCIM</strong> tab in the organization detail page of the admin UI for configuring authentication and provisioning behavior.</li>
<li class="">A REST configuration API at <code>/realms/{realm}/orgs/{orgId}/scim</code> for the same operations.</li>
<li class="">Four authentication modes for inbound provisioning calls: <code>KEYCLOAK</code> (Bearer access token), <code>EXTERNAL_JWT</code> (validated against an upstream issuer + JWKS), <code>EXTERNAL_SECRET</code> (shared bearer secret), and <code>EXTERNAL_BASIC</code> (HTTP Basic). Cleartext secrets and passwords are hashed with Argon2id before persistence.</li>
<li class="">Optional federation to the organization's configured identity provider, so provisioned users can immediately sign in via the org's existing SSO.</li>
</ul>
<p>The full surface is documented in <a href="https://github.com/p2-inc/keycloak-orgs/blob/main/docs/scim.md" target="_blank" rel="noopener noreferrer" class=""><code>docs/scim.md</code></a> in the keycloak-orgs repo.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-experimental-means-here">What "experimental" means here<a href="https://phasetwo.io/blog/orgs-scim-experimental/#what-experimental-means-here" class="hash-link" aria-label="Direct link to What &quot;experimental&quot; means here" title="Direct link to What &quot;experimental&quot; means here" translate="no">​</a></h3>
<p>The API shape, the configuration schema, and the realm-level enablement flag may change in a backwards-incompatible way before we mark it stable. If you take a dependency, pin to a specific version of <code>keycloak-orgs</code> and read the changelog before upgrading.</p>
<p>That caveat isn't theoretical — we're still iterating on the authentication mode set, the configuration shape for credential rotation, and the exact semantics around the IdP linkage. We'd rather get those right than freeze them prematurely.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="credit-where-its-due-metatavus-keycloak-scim-server">Credit where it's due: Metatavu's keycloak-scim-server<a href="https://phasetwo.io/blog/orgs-scim-experimental/#credit-where-its-due-metatavus-keycloak-scim-server" class="hash-link" aria-label="Direct link to Credit where it's due: Metatavu's keycloak-scim-server" title="Direct link to Credit where it's due: Metatavu's keycloak-scim-server" translate="no">​</a></h2>
<p>The SCIM 2.0 server implementation underneath this is <a href="https://github.com/Metatavu/keycloak-scim-server" target="_blank" rel="noopener noreferrer" class="">Metatavu's <code>keycloak-scim-server</code></a> — an open-source SCIM provider that handles the protocol-level work of speaking SCIM 2.0 to upstream IdPs. We've been building on top of it, contributing back, and maintaining a fork tuned for the per-organization model.</p>
<p>Metatavu's project is itself under active development, and so is our use of it. The two efforts are happening in parallel: Metatavu is iterating on the core SCIM server, and we're iterating on the organization-aware layer that sits on top. Neither codebase is "done."</p>
<p>What both projects do have, despite the experimental label, is <strong>active production users today</strong>. Metatavu has been running their SCIM server in real deployments, and Phase Two customers are already using the per-organization variant against real upstream IdPs. The "experimental" tag reflects the rate of change in the API surface, not the maturity of the underlying provisioning logic.</p>
<p>If you're evaluating this for a production rollout, the realistic posture is: it works, it's being used, and you should expect to update your integration when the configuration shape evolves. Pin versions and follow the changelog.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="getting-started">Getting started<a href="https://phasetwo.io/blog/orgs-scim-experimental/#getting-started" class="hash-link" aria-label="Direct link to Getting started" title="Direct link to Getting started" translate="no">​</a></h2>
<p>If you're already running Phase Two's Keycloak extensions, upgrade to the latest <code>keycloak-orgs</code>, enable the realm-level <code>scimEnabled</code> flag, and the SCIM tab will appear on each organization. The <a href="https://github.com/p2-inc/keycloak-orgs/blob/main/docs/scim.md" target="_blank" rel="noopener noreferrer" class="">SCIM docs</a> walk through the realm flag, the per-organization configuration, and each of the four authentication modes.</p>
<p>If you want to skip the self-host setup, you can try this on a Starter cluster in the <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Phase Two Dashboard</a>, free for 30 days — the same Organization SCIM capability is available there.</p>
<p>Feedback, bug reports, and use-case stories are all welcome on the <a href="https://github.com/p2-inc/keycloak-orgs/issues" target="_blank" rel="noopener noreferrer" class="">keycloak-orgs issue tracker</a> or directly to <a href="mailto:support@phasetwo.io" target="_blank" rel="noopener noreferrer" class="">support@phasetwo.io</a>. The faster we hear from real integrations, the faster we get this to stable.</p>]]></content:encoded>
            <category>phase_two</category>
            <category>keycloak</category>
            <category>organizations</category>
            <category>scim</category>
            <category>provisioning</category>
            <category>open_source</category>
        </item>
        <item>
            <title><![CDATA[A New Keycloak Theme Experience: Login, Admin, Account, and Email]]></title>
            <link>https://phasetwo.io/blog/new-keycloak-themes/</link>
            <guid>https://phasetwo.io/blog/new-keycloak-themes/</guid>
            <pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[We rebuilt our Keycloak themes from the ground up using Keycloakify and shadcn/ui—delivering a modern login experience, a richer admin console, a polished account portal, and fully branded emails, all configurable at runtime without redeployment.]]></description>
            <content:encoded><![CDATA[<p>Keycloak theming has always been a pain point. The default themes that come with Keycloak leave a lot to be desired stylistically and cannot be customized easily. We have maintained our own set of disparate custom themes for the login, email and admin consoles but that has led to a maintenance nightmare and a disjointed user experience.</p>
<p>We've completely rebuilt our bundled Keycloak themes. What used to live as a tangle of custom pages inside a forked Keycloak repository is now a first-class <a href="https://www.keycloakify.dev/" target="_blank" rel="noopener noreferrer" class="">Keycloakify</a>-based React application that ships four themes: <strong>login</strong>, <strong>admin</strong>, <strong>account</strong>, and <strong>email</strong>. The result is faster to maintain, far more capable, and dramatically better out of the box for the organizations using Phase Two today.</p>
<p>Starting now, <strong>all Phase Two containers ship with this theme bundled</strong>. Any realm you create through the <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Phase Two Dashboard</a> automatically gets the new login, admin, account, and email themes active—no configuration required. The first time a user hits your login page or receives an email from your realm, it already looks good.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="why-we-did-it">Why We Did It<a href="https://phasetwo.io/blog/new-keycloak-themes/#why-we-did-it" class="hash-link" aria-label="Direct link to Why We Did It" title="Direct link to Why We Did It" translate="no">​</a></h2>
<p>The old setup worked, but it accumulated debt fast. We maintained a fork of the Keycloak repository with admin UI customizations living on branches named things like <code>26.3.0_orgs_adminui</code>. Every Keycloak release meant rebasing those changes, resolving conflicts across thousands of lines of upstream JavaScript, and hoping nothing subtle broke. Testing required spinning up a full Keycloak image just to verify a button label.</p>
<p>Beyond the maintenance burden, the old approach had strong limits on what we could do. The login theme was limited by what you could do with PatternFly (RedHat's design framework). The email theme was limited to just overrides but you couldn't easily brand it. The admin console tweaks were limited to what we could fit into the existing UI without breaking things. And we had not even touched the account theme, which meant users had a completely different experience once they logged in. Lastly, when users jumped from our dashboard into the Keycloak admin console, the experience was jarring because it didn't reflect the product they had just been using.</p>
<p>We wanted:</p>
<ul>
<li class=""><strong>Maintainability</strong> that didn't require tracking every Keycloak release by hand</li>
<li class=""><strong>Runtime configurability</strong> so customers can change logos and colors easily without needing to redeploy or write custom themes</li>
<li class=""><strong>A richer admin experience</strong> for managing organizations, members, roles, and styles</li>
<li class=""><strong>A modern, polished look</strong> that doesn't feel like enterprise software from 2015</li>
<li class=""><strong>A proper dashboard</strong> for navigating what Phase Two adds on top of Keycloak</li>
<li class=""><strong>Modern fonts, layouts, and components</strong> that look good. Something about the RedHat fonts didn't seem to have the polish that most modern products have</li>
</ul>
<p>A combination of Keycloakify and Phase Two's large extension capabilities made it possible to build this without needing to fork Keycloak or maintain custom JARs with static templates.</p>
<p>If you want to start trying it now, grab our latest image from <a href="https://quay.io/repository/phasetwo/phasetwo-keycloak" target="_blank" rel="noopener noreferrer" class="">Quay.io</a> or try it out on the <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Phase Two Dashboard</a>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-stack">The Stack<a href="https://phasetwo.io/blog/new-keycloak-themes/#the-stack" class="hash-link" aria-label="Direct link to The Stack" title="Direct link to The Stack" translate="no">​</a></h2>
<p>The new theme, called <code>phasetwo-ui</code>, is a React application built with:</p>
<ul>
<li class=""><strong>Keycloakify 11</strong> — bridges React components to Keycloak's theme SPI, handles JAR packaging automatically</li>
<li class=""><strong>shadcn/ui + Tailwind CSS 4</strong> — modern, composable component library for the login and account themes</li>
<li class=""><strong>PatternFly 5</strong> — used in the admin and account console to avoid a complete rewrite of those complex interfaces while still allowing us to inject our own styles and components</li>
<li class=""><strong>Vite</strong> — fast builds and hot module replacement during development</li>
<li class=""><strong>Storybook</strong> — isolated component development and visual testing</li>
<li class=""><strong>Playwright</strong> — end-to-end tests that exercise the admin UI against a real Keycloak instance</li>
</ul>
<p>The Java side extends the Keycloak SPI to handle runtime theme loading, dynamic CSS generation, Mustache template rendering, and a configurable email sender. All of it ships as a single JAR dropped into the Keycloak <code>providers/</code> directory.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="login-theme">Login Theme<a href="https://phasetwo.io/blog/new-keycloak-themes/#login-theme" class="hash-link" aria-label="Direct link to Login Theme" title="Direct link to Login Theme" translate="no">​</a></h2>
<p>Out of the box, Keycloak's default login pages are functional but bare. The new <code>phasetwo-ui</code> login theme replaces them entirely with something that looks like a product, not a framework demo. It's a two-column layout with the branding panel on the right on desktop and a mobile-optimized single-column view on smaller screens. It supports all 21+ standard Keycloak login flows: standard authentication, registration, password reset, TOTP setup, WebAuthn enrollment, account deletion, email verification, identity provider linking, OAuth consent, and error handling.</p>
<p>For new Phase Two realms, this is now the default. Your users will see this from day one.</p>
<p><img decoding="async" loading="lazy" alt="Login Theme" src="https://phasetwo.io/assets/images/login-520f3113e3ce0a5e2d5a6f7012c81408.png" width="3112" height="1712" class="img_R7yg"></p>
<p>The bigger story is runtime configurability. Every visual element of the login page is controlled by realm attributes—no redeploy required:</p>
<ul>
<li class=""><strong>Logo</strong> — set a URL and it shows up immediately</li>
<li class=""><strong>Favicon and app icon</strong> — same approach, including PWA-ready app icons</li>
<li class=""><strong>Primary, secondary, and background colors</strong> — picked in the admin UI, reflected instantly in login</li>
<li class=""><strong>Custom CSS</strong> — inject arbitrary overrides without touching templates</li>
</ul>
<p><img decoding="async" loading="lazy" alt="Login Theme Customized" src="https://phasetwo.io/assets/images/login-theme-customized-51c0a7c60797e719f40ad295811d0547.png" width="3898" height="1678" class="img_R7yg"></p>
<p>These attributes are served through a dynamic <code>/realms/&lt;realm&gt;/assets/css/login.css</code> endpoint that the Java SPI generates on the fly, mapping realm attributes to the right CSS variables.</p>
<p>Dark mode is supported. Language switching is built in. Fallback logos handle the case where a custom logo URL fails to load.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="admin-theme">Admin Theme<a href="https://phasetwo.io/blog/new-keycloak-themes/#admin-theme" class="hash-link" aria-label="Direct link to Admin Theme" title="Direct link to Admin Theme" translate="no">​</a></h2>
<p>This is where the most work went, some visual but a lot of it underlying plumbing. The admin theme extends Keycloak's native admin console with a dedicated <strong>Phase Two</strong> panel that surfaces everything our extensions add.</p>
<p><img decoding="async" loading="lazy" alt="Admin Theme" src="https://phasetwo.io/assets/images/admin-theme-c15ee3752ea7444e9771c11f42ab1ade.png" width="3046" height="1818" class="img_R7yg"></p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="organizations">Organizations<a href="https://phasetwo.io/blog/new-keycloak-themes/#organizations" class="hash-link" aria-label="Direct link to Organizations" title="Direct link to Organizations" translate="no">​</a></h3>
<p>The organizations section is a full CRUD interface for everything the <a href="https://github.com/p2-inc/phasetwo-keycloak" target="_blank" rel="noopener noreferrer" class="">Keycloak Organizations extension</a> provides:</p>
<ul>
<li class=""><strong>Organization list</strong> with creation and deletion</li>
<li class=""><strong>Organization details</strong> — name, description, display name, attributes</li>
<li class=""><strong>Members</strong> — add, remove, view member details and organization-scoped attributes</li>
<li class=""><strong>Roles</strong> — create and manage roles that are scoped to a specific organization</li>
<li class=""><strong>Identity Providers</strong> — assign IdPs to organizations and configure sync modes</li>
<li class=""><strong>Domains</strong> — domain-based routing configuration</li>
<li class=""><strong>Invitations</strong> — send and manage membership invitations by email</li>
<li class=""><strong>Settings</strong> — enabled/disabled state and other org-level configuration</li>
</ul>
<p>The API calls are fully typed against our OpenAPI schema, so the UI and the API stay in sync automatically.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="custom-styles">Custom Styles<a href="https://phasetwo.io/blog/new-keycloak-themes/#custom-styles" class="hash-link" aria-label="Direct link to Custom Styles" title="Direct link to Custom Styles" translate="no">​</a></h3>
<p>The styles panel gives realm administrators direct control over branding without writing any code. It has four tabs:</p>
<ol>
<li class=""><strong>General</strong> — upload URLs for logo, favicon, and app icon with live image previews</li>
<li class=""><strong>Login</strong> — color pickers for primary, secondary, and background colors plus a CSS editor for arbitrary overrides</li>
<li class=""><strong>Email</strong> — customize email template branding including footer text</li>
<li class=""><strong>Portal</strong> — portal-specific styling controls for the Admin Portal, a self-serve application for end users to manage their account and Organization memberships without needing access to the full Keycloak admin console</li>
</ol>
<p>Everything saves directly to realm attributes. The login page picks up changes immediately because the CSS endpoint reads those attributes on every request.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="account-theme">Account Theme<a href="https://phasetwo.io/blog/new-keycloak-themes/#account-theme" class="hash-link" aria-label="Direct link to Account Theme" title="Direct link to Account Theme" translate="no">​</a></h2>
<p>The account theme wraps Keycloak's standard account management UI in the same visual language as the login and admin themes. Users get a consistent branded experience whether they're logging in, managing their sessions, or updating their profile. A session expiration warning overlay prevents users from losing unsaved changes mid-session.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="email-theme">Email Theme<a href="https://phasetwo.io/blog/new-keycloak-themes/#email-theme" class="hash-link" aria-label="Direct link to Email Theme" title="Direct link to Email Theme" translate="no">​</a></h2>
<p>The default Keycloak email templates are plain text in a minimal HTML shell. They get the job done, but they don't reflect well on the product they're sending from. Our new email theme replaces that with a professional, fully branded layout—clean typography, your logo embedded directly in the message, a structured content area with appropriate padding and shadow, and a configurable footer. All coupled with an in-page preview experience. Emails from your realm will look like they came from a real product from day one.</p>
<p>For realms created through the Phase Two Dashboard, this is now the default. No setup required.</p>
<p><img decoding="async" loading="lazy" alt="Email Theme" src="https://phasetwo.io/assets/images/email-theme-configuration-2b4f8ff91b44e39c35c7aebfe6819d2c.png" width="2656" height="2080" class="img_R7yg"></p>
<p>Beyond the out-of-the-box look, we addressed the deeper problem: email was arguably the most rigid part of the old setup. Static Freemarker templates meant any copy or branding change required a build and deploy. We replaced that with two mechanisms.</p>
<p><strong>Attribute-based template overrides</strong> — any <code>.ftl</code> template can be overridden by setting a realm attribute with the key <code>_providerConfig.theme.email.templates.&lt;template-name.ftl&gt;</code>. Message strings work the same way via <code>_providerConfig.theme.email.messages.&lt;message-key&gt;</code>. No redeploy, no file system access required.</p>
<p><strong>Mustache template support</strong> — we ship an alternative <code>EmailTemplateProvider</code> implementation that renders Mustache templates in addition to Freemarker. This opens up a simpler, more readable template syntax and lets customers supply their own templates through the admin UI without needing to understand Freemarker's quirks.</p>
<p>The email layout is configurable without touching templates: set footer text lines and a logo URL from the <strong>Phase Two → Styles → Email</strong> panel in the admin console and they reflect immediately.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-dashboard">The Dashboard<a href="https://phasetwo.io/blog/new-keycloak-themes/#the-dashboard" class="hash-link" aria-label="Direct link to The Dashboard" title="Direct link to The Dashboard" translate="no">​</a></h2>
<p>One of the additions we're most excited about is the Phase Two Dashboard—a dedicated view in the admin console that gives realm administrators a high-level picture of their deployment at a glance. Organizations, members, and activity stats surface immediately without having to drill into individual sections. It's the kind of overview that makes Keycloak feel less like a configuration interface and more like a product. We have started basic here, but will continue to expand the dashboard with more insights and actionable items over time.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="easier-to-maintain">Easier to Maintain<a href="https://phasetwo.io/blog/new-keycloak-themes/#easier-to-maintain" class="hash-link" aria-label="Direct link to Easier to Maintain" title="Direct link to Easier to Maintain" translate="no">​</a></h2>
<p>The practical benefit for us is that upgrading Keycloak no longer means rebasing thousands of lines of forked admin UI JavaScript. Keycloakify handles the bridge between our React components and the Keycloak theme SPI. When Keycloak's admin UI packages update, we update our Keycloakify dependencies. Our customizations live in well-defined extension points, not in modified copies of upstream files.</p>
<p>We also get proper tooling for the first time: Storybook for developing components in isolation, Playwright tests that exercise the admin UI against a real Keycloak instance, and a <code>docker compose up</code> local environment that lets any developer work on the theme without needing deep Keycloak internals knowledge.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="how-to-get-it">How to Get It<a href="https://phasetwo.io/blog/new-keycloak-themes/#how-to-get-it" class="hash-link" aria-label="Direct link to How to Get It" title="Direct link to How to Get It" translate="no">​</a></h2>
<p><strong>Phase Two managed service</strong> — it's already there. All containers now ship with the <code>phasetwo-ui</code> theme bundled. New realms created through the <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Phase Two Dashboard</a> have it active automatically. Existing realms can switch by setting <code>phasetwo-ui</code> as the theme for login, admin, account, and email in realm settings, then configuring branding from the <strong>Phase Two → Styles</strong> panel.</p>
<p><strong>Self-hosted with the Phase Two image</strong> — the theme is included in the <a href="https://quay.io/repository/phasetwo/phasetwo-keycloak" target="_blank" rel="noopener noreferrer" class="">Phase Two Keycloak image</a>. Pull the latest image and select <code>phasetwo-ui</code> in your realm theme settings.</p>
<p><strong>Self-hosted with your own Keycloak</strong> — build and install from the open source <a href="https://github.com/p2-inc/keycloak-themes" target="_blank" rel="noopener noreferrer" class="">keycloak-themes</a> repository:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">mvn clean install -DskipTests</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">cp target/keycloak-themes-*.jar /path/to/keycloak/providers/</span><br></div></code></pre></div></div>
<p>Restart Keycloak and select <code>phasetwo-ui</code> for login, admin, account, and email in your realm configuration.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="looking-forward">Looking Forward<a href="https://phasetwo.io/blog/new-keycloak-themes/#looking-forward" class="hash-link" aria-label="Direct link to Looking Forward" title="Direct link to Looking Forward" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="visual-editor-for-login-and-email">Visual editor for login and email<a href="https://phasetwo.io/blog/new-keycloak-themes/#visual-editor-for-login-and-email" class="hash-link" aria-label="Direct link to Visual editor for login and email" title="Direct link to Visual editor for login and email" translate="no">​</a></h3>
<p>The styles panel today gives you color pickers, a logo field, and a CSS editor. That covers a lot, but we want to go further. We're building a visual editor directly into the Phase Two Dashboard that lets you make fine-grained design changes to your login and email themes—adjusting layout, spacing, typography, background images, and component styles through a live preview interface rather than writing CSS by hand.</p>
<p>The goal isn't to replace a fully bespoke login experience. Some brands need total control, and the custom CSS path will always be there for that. But for the vast majority of teams, getting 95% of the way there without a designer or a build pipeline is exactly what's needed. Your login page should feel like it belongs to your product, and we think that should be achievable in an afternoon, not a sprint.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="more-capability-in-the-admin-and-account-uis">More capability in the Admin and Account UIs<a href="https://phasetwo.io/blog/new-keycloak-themes/#more-capability-in-the-admin-and-account-uis" class="hash-link" aria-label="Direct link to More capability in the Admin and Account UIs" title="Direct link to More capability in the Admin and Account UIs" translate="no">​</a></h3>
<p>We're continuing to expand what the admin and account UIs can do. On the admin side, more of our extension capabilities will surface directly in the Phase Two panel as we add features—so you're managing everything through the console rather than through API calls or raw attribute editing.</p>
<p>On the account side, we're reworking several views to feel more like a modern product interface. One early change is moving the account list views from a table layout to a tile-based interface—easier to scan, more room for useful metadata, more familiar UX, and a look that fits better alongside the rest of the theme.</p>
<p>For email themes, we're adding more template variables and making it easier to customize the content and layout of emails without needing to write custom templates.</p>
<p>If you have feedback or run into anything, open an issue on <a href="https://github.com/p2-inc/keycloak-themes" target="_blank" rel="noopener noreferrer" class="">GitHub</a> or <a href="https://phasetwo.io/contact" target="_blank" rel="noopener noreferrer" class="">drop us a line</a>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="need-help-customizing-your-keycloak-experience">Need help customizing your Keycloak experience?<a href="https://phasetwo.io/blog/new-keycloak-themes/#need-help-customizing-your-keycloak-experience" class="hash-link" aria-label="Direct link to Need help customizing your Keycloak experience?" title="Direct link to Need help customizing your Keycloak experience?" translate="no">​</a></h2>
<p>Whether you're trying to match your brand exactly, integrate a custom identity flow, or just figure out where to start—we're happy to help. <a href="https://phasetwo.io/contact" target="_blank" rel="noopener noreferrer" class="">Reach out to us</a> and we'll point you in the right direction.</p>]]></content:encoded>
            <category>phase_two</category>
            <category>keycloak</category>
            <category>themes</category>
            <category>open_source</category>
            <category>admin-ui</category>
            <category>keycloakify</category>
            <category>organizations</category>
            <category>branding</category>
        </item>
        <item>
            <title><![CDATA[Instant MCP authorization using Keycloak]]></title>
            <link>https://phasetwo.io/blog/instant-mcp-authorization-keycloak/</link>
            <guid>https://phasetwo.io/blog/instant-mcp-authorization-keycloak/</guid>
            <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Protect an MCP server with Keycloak, wire it into VS Code using dynamic client registration, and get a working local setup quickly.]]></description>
            <content:encoded><![CDATA[<p>If you are exposing tools over MCP, you usually do not want every client on the network calling them anonymously. Even for a local prototype, you typically want a real login flow, consent, scoped access tokens, and a clean way to validate who is allowed to run what.</p>
<p>Keycloak is the easiest way to do that without inventing your own authorization layer. It already handles browser login, consent, token issuance, JWKS discovery, and OAuth metadata. Your MCP server just needs to behave like a protected resource and validate bearer tokens correctly.</p>
<p>In this guide, we will build a tiny calculator MCP server in Python, protect it with Keycloak, and connect to it from VS Code using Dynamic Client Registration (DCR). By the end, VS Code will open a browser to Keycloak, you will sign in, approve access to the <code>mcp:run</code> scope, and then call your MCP tools directly from chat.</p>
<p>This walkthrough uses:</p>
<ul>
<li class="">The official MCP Python SDK</li>
<li class="">One MCP endpoint at <code>http://localhost:8000/mcp</code></li>
<li class="">Keycloak as the authorization server</li>
<li class="">A Keycloak client scope named <code>mcp:run</code></li>
<li class="">An Audience mapper that sets <code>aud = http://localhost:8000/mcp</code></li>
<li class=""><code>Include in token scope</code> enabled for <code>mcp:run</code></li>
<li class="">Trusted Hosts removed from anonymous client registration policies so VS Code's DCR request is accepted</li>
</ul>
<p>If you do not want to host and configure Keycloak yourself, you can create a Starter cluster in the <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Phase Two Dashboard</a> — it comes with a 30-day free trial — and use that instead of a self-hosted local Keycloak instance. The rest of the MCP-side setup stays the same.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-you-are-building">What you are building<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#what-you-are-building" class="hash-link" aria-label="Direct link to What you are building" title="Direct link to What you are building" translate="no">​</a></h2>
<p>We will create a small MCP server that exposes three tools:</p>
<ul>
<li class=""><code>add_numbers</code></li>
<li class=""><code>multiply_numbers</code></li>
<li class=""><code>divide_numbers</code></li>
</ul>
<p>The server will run locally and expose its MCP endpoint at:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">http://localhost:8000/mcp</span><br></div></code></pre></div></div>
<p>VS Code will connect to that URL, discover the MCP server's protected-resource metadata, discover Keycloak as the authorization server, dynamically register itself, send you through the browser login flow, and then call the tools with an access token.</p>
<p>That is the key mental model: the MCP server is the protected resource, and Keycloak is the authorization server.</p>
<p>A high-level diagram of the topology looks like this:</p>
<p><img decoding="async" loading="lazy" alt="MCP Keycloak Topology" src="https://phasetwo.io/assets/images/dataflow-topology-260c20ed19273edc256901c1c12c04b9.png" width="2758" height="1376" class="img_R7yg"></p>
<p>with data flowing through it like this:</p>
<p><img decoding="async" loading="lazy" alt="MCP Keycloak Dataflow Sequence" src="https://phasetwo.io/assets/images/dataflow-sequence-24be5b0ff9b6ab97ad0923f5e284c423.png" width="2764" height="1804" class="img_R7yg"></p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-you-need-installed">What you need installed<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#what-you-need-installed" class="hash-link" aria-label="Direct link to What you need installed" title="Direct link to What you need installed" translate="no">​</a></h2>
<p>Install these first:</p>
<ul>
<li class="">Python 3.11 or newer</li>
<li class="">Docker Desktop</li>
<li class="">VS Code</li>
<li class="">GitHub Copilot or another model-enabled chat workflow in VS Code</li>
</ul>
<p>Then create a virtual environment and install dependencies:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">mkdir keycloak-mcp-demo</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">cd keycloak-mcp-demo</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">python -m venv .venv</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">source .venv/bin/activate</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">pip install "mcp[cli]" "pyjwt[crypto]&gt;=2.8.0"</span><br></div></code></pre></div></div>
<p>This setup uses the official MCP Python SDK and PyJWT to validate Keycloak-issued access tokens.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="how-the-auth-flow-works">How the auth flow works<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#how-the-auth-flow-works" class="hash-link" aria-label="Direct link to How the auth flow works" title="Direct link to How the auth flow works" translate="no">​</a></h2>
<p>When VS Code first hits your MCP endpoint, it does not already have a token. The server should reject that first request with <code>401 Unauthorized</code> and include a <code>WWW-Authenticate</code> header pointing to a protected-resource metadata document.</p>
<p>VS Code then:</p>
<ol>
<li class="">Fetches the protected-resource metadata</li>
<li class="">Learns which authorization server protects this MCP server</li>
<li class="">Discovers the authorization server metadata from Keycloak</li>
<li class="">Dynamically registers a public client</li>
<li class="">Opens your browser for login and consent</li>
<li class="">Gets an access token back</li>
<li class="">Retries the MCP request with <code>Authorization: Bearer ...</code></li>
</ol>
<p>For this to work, two endpoints have to behave correctly:</p>
<ul>
<li class=""><code>GET /mcp</code> must return a <code>401</code> challenge when no token is present</li>
<li class=""><code>GET /.well-known/oauth-protected-resource/mcp</code> must advertise the Keycloak realm and the exact MCP resource URL</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-1-create-the-mcp-server">Step 1: Create the MCP server<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#step-1-create-the-mcp-server" class="hash-link" aria-label="Direct link to Step 1: Create the MCP server" title="Direct link to Step 1: Create the MCP server" translate="no">​</a></h2>
<p>Download the complete example server here:</p>
<ul>
<li class="">
<a href="https://phasetwo.io/blog/mcp_authorization_keycloak/keycloak-mcp-server.py">server.py</a>
</li>
</ul>
<p>This server:</p>
<ul>
<li class="">Validates the token signature against Keycloak's JWKS</li>
<li class="">Checks the token issuer</li>
<li class="">Checks that the token audience matches the exact MCP URL</li>
<li class="">Checks that the <code>mcp:run</code> scope appears in the token's <code>scope</code> claim</li>
</ul>
<p>Start the server:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">python server.py</span><br></div></code></pre></div></div>
<p>That <code>scope</code> check matters. If <code>mcp:run</code> exists as a Keycloak client scope but is not included in token scope, the access token can come back with the correct audience but an empty <code>scope</code> claim, and the server will reject it. If you hit this, paste the access token into our <a class="" href="https://phasetwo.io/tools/jwt-decoder/">JWT Decoder</a> — it surfaces the <code>scope</code> claim and <code>aud</code> so you can see exactly what Keycloak emitted.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-2-verify-the-server-before-using-vs-code">Step 2: Verify the server before using VS Code<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#step-2-verify-the-server-before-using-vs-code" class="hash-link" aria-label="Direct link to Step 2: Verify the server before using VS Code" title="Direct link to Step 2: Verify the server before using VS Code" translate="no">​</a></h2>
<p>Before touching Keycloak or VS Code, make sure the server is advertising the right OAuth metadata.</p>
<p>Check the protected-resource metadata:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">curl -s http://localhost:8000/.well-known/oauth-protected-resource/mcp</span><br></div></code></pre></div></div>
<p>You should get something like:</p>
<div class="language-json codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-json codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"resource"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"http://localhost:8000/mcp"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"authorization_servers"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">[</span><span class="token string" style="color:rgb(195, 232, 141)">"http://localhost:8080/realms/mcp-demo"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">]</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"scopes_supported"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">[</span><span class="token string" style="color:rgb(195, 232, 141)">"mcp:run"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">]</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"bearer_methods_supported"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">[</span><span class="token string" style="color:rgb(195, 232, 141)">"header"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></div></code></pre></div></div>
<p>Then check the unauthenticated MCP endpoint:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">curl -i http://localhost:8000/mcp</span><br></div></code></pre></div></div>
<p>You should get <code>401 Unauthorized</code>, and the <code>WWW-Authenticate</code> header should point to:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp"</span><br></div></code></pre></div></div>
<p>If those two responses are correct, VS Code has the discovery information it needs.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-3-start-keycloak-locally">Step 3: Start Keycloak locally<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#step-3-start-keycloak-locally" class="hash-link" aria-label="Direct link to Step 3: Start Keycloak locally" title="Direct link to Step 3: Start Keycloak locally" translate="no">​</a></h2>
<p>Run Keycloak in development mode:</p>
<div class="language-bash codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-bash codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">docker run --rm \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -p 127.0.0.1:8080:8080 \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  quay.io/keycloak/keycloak start-dev</span><br></div></code></pre></div></div>
<p>Then open:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">http://localhost:8080</span><br></div></code></pre></div></div>
<p>Sign in as:</p>
<ul>
<li class="">Username: <code>admin</code></li>
<li class="">Password: <code>admin</code></li>
</ul>
<p>If you want to skip self-hosting, create a Starter cluster in the <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Phase Two Dashboard</a> instead — it starts with a 30-day free trial. For this tutorial, that is usually the fastest route because you avoid local Keycloak setup, bootstrap admin credentials, and one-off realm configuration from scratch.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-4-create-the-realm-and-a-test-user">Step 4: Create the realm and a test user<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#step-4-create-the-realm-and-a-test-user" class="hash-link" aria-label="Direct link to Step 4: Create the realm and a test user" title="Direct link to Step 4: Create the realm and a test user" translate="no">​</a></h2>
<p>Create a realm named:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">mcp-demo</span><br></div></code></pre></div></div>
<p>Then create a test user such as <code>alice</code>, set a password, and make sure you can sign in with it.</p>
<p>The realm issuer your server expects is:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">http://localhost:8080/realms/mcp-demo</span><br></div></code></pre></div></div>
<p>That exact URL must match the issuer your Python server validates.</p>
<p>If you want a faster setup than clicking through each screen manually, you can also import the example realm configuration:</p>
<ul>
<li class="">
<a href="https://phasetwo.io/blog/mcp_authorization_keycloak/keycloak-realm-export.json">realm-export.json</a>
</li>
</ul>
<p>If you are using a hosted realm from Phase Two instead of local Keycloak, use the hosted realm's issuer URL everywhere the tutorial currently shows <code>http://localhost:8080/realms/mcp-demo</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-5-create-the-mcprun-client-scope">Step 5: Create the <code>mcp:run</code> client scope<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#step-5-create-the-mcprun-client-scope" class="hash-link" aria-label="Direct link to step-5-create-the-mcprun-client-scope" title="Direct link to step-5-create-the-mcprun-client-scope" translate="no">​</a></h2>
<p>Create a new client scope named:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">mcp:run</span><br></div></code></pre></div></div>
<p>Then configure it like this:</p>
<ul>
<li class=""><code>Include in token scope</code>: ON</li>
<li class=""><code>Display on consent screen</code>: ON is fine</li>
</ul>
<p>That first setting is essential for this implementation. The server checks the access token's <code>scope</code> claim, so Keycloak must actually emit <code>mcp:run</code> into the token.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="add-the-audience-mapper">Add the Audience mapper<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#add-the-audience-mapper" class="hash-link" aria-label="Direct link to Add the Audience mapper" title="Direct link to Add the Audience mapper" translate="no">​</a></h3>
<p>Inside <code>mcp:run</code>, add an Audience mapper with:</p>
<ul>
<li class=""><code>Included Custom Audience</code>: <code>http://localhost:8000/mcp</code></li>
<li class=""><code>Add to access token</code>: ON</li>
<li class=""><code>Add to introspection</code>: ON is fine</li>
<li class=""><code>Add to ID token</code>: OFF is fine</li>
</ul>
<p>This is what causes the access token to carry:</p>
<div class="language-json codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-json codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token property">"aud"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"http://localhost:8000/mcp"</span><br></div></code></pre></div></div>
<p>That audience must exactly match the MCP server URL the Python server expects.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-6-make-mcprun-available-to-dcr-created-clients">Step 6: Make <code>mcp:run</code> available to DCR-created clients<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#step-6-make-mcprun-available-to-dcr-created-clients" class="hash-link" aria-label="Direct link to step-6-make-mcprun-available-to-dcr-created-clients" title="Direct link to step-6-make-mcprun-available-to-dcr-created-clients" translate="no">​</a></h2>
<p>For VS Code DCR to work smoothly, dynamically registered public clients must be able to get the <code>mcp:run</code> scope without any manual client editing.</p>
<p>There are two places to check.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="add-mcprun-to-the-realms-default-client-scopes">Add <code>mcp:run</code> to the realm's default client scopes<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#add-mcprun-to-the-realms-default-client-scopes" class="hash-link" aria-label="Direct link to add-mcprun-to-the-realms-default-client-scopes" title="Direct link to add-mcprun-to-the-realms-default-client-scopes" translate="no">​</a></h3>
<p>Make sure <code>mcp:run</code> is included in the realm's default client scopes.</p>
<p>That way, a newly registered public client will inherit it automatically.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="allow-mcprun-in-anonymous-client-registration">Allow <code>mcp:run</code> in anonymous client registration<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#allow-mcprun-in-anonymous-client-registration" class="hash-link" aria-label="Direct link to allow-mcprun-in-anonymous-client-registration" title="Direct link to allow-mcprun-in-anonymous-client-registration" translate="no">​</a></h3>
<p>In the realm's client registration policies, keep the anonymous Allowed Client Scopes policy and make sure it includes:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">mcp:run</span><br></div></code></pre></div></div>
<p>That tells Keycloak that anonymous DCR clients are allowed to request the MCP scope.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-7-remove-the-trusted-hosts-policy">Step 7: Remove the Trusted Hosts policy<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#step-7-remove-the-trusted-hosts-policy" class="hash-link" aria-label="Direct link to Step 7: Remove the Trusted Hosts policy" title="Direct link to Step 7: Remove the Trusted Hosts policy" translate="no">​</a></h2>
<p>This is the piece that tends to block VS Code.</p>
<p>VS Code's DCR request includes multiple URLs, including redirect URIs for <code>vscode.dev</code>, <code>insiders.vscode.dev</code>, <code>localhost</code>, and <code>127.0.0.1</code>, plus a client URI for the VS Code website. Keycloak's default Trusted Hosts registration policy rejects that request.</p>
<p>For this local tutorial setup, remove the Trusted Hosts policy from the realm's anonymous client-registration policies.</p>
<p>That is what allows VS Code's DCR payload to succeed.</p>
<p>You can keep the other anonymous registration policies in place, especially:</p>
<ul>
<li class="">Allowed Client Scopes</li>
<li class="">Allowed Registration Web Origins</li>
<li class="">Allowed Protocol Mapper Types</li>
<li class="">Consent Required</li>
<li class="">Max Clients Limit</li>
</ul>
<p>But Trusted Hosts needs to go for this workflow.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-8-configure-vs-code">Step 8: Configure VS Code<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#step-8-configure-vs-code" class="hash-link" aria-label="Direct link to Step 8: Configure VS Code" title="Direct link to Step 8: Configure VS Code" translate="no">​</a></h2>
<p>Create <code>.vscode/mcp.json</code>, or download the example file:</p>
<ul>
<li class="">
<a href="https://phasetwo.io/blog/mcp_authorization_keycloak/keycloak-mcp.json">mcp.json</a>
</li>
</ul>
<p>The contents should look like:</p>
<div class="language-json codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-json codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token property">"servers"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token property">"calculator-local"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">      </span><span class="token property">"type"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"http"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">      </span><span class="token property">"url"</span><span class="token operator" style="color:rgb(137, 221, 255)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"http://localhost:8000/mcp"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></div></code></pre></div></div>
<p>Then in VS Code:</p>
<ol>
<li class="">Open the project</li>
<li class="">Open the Command Palette</li>
<li class="">Run <code>MCP: List Servers</code></li>
<li class="">Confirm <code>calculator-local</code> appears</li>
<li class="">Start it</li>
</ol>
<p>If you previously had failed auth attempts cached, run:</p>
<ul>
<li class=""><code>Authentication: Remove Dynamic Authentication Providers</code></li>
</ul>
<p>Then restart VS Code before trying again.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="step-9-sign-in-through-keycloak">Step 9: Sign in through Keycloak<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#step-9-sign-in-through-keycloak" class="hash-link" aria-label="Direct link to Step 9: Sign in through Keycloak" title="Direct link to Step 9: Sign in through Keycloak" translate="no">​</a></h2>
<p>Once VS Code connects to the server, it should:</p>
<ol>
<li class="">Discover the protected-resource metadata at <code>/.well-known/oauth-protected-resource/mcp</code></li>
<li class="">Discover Keycloak as the authorization server</li>
<li class="">Dynamically register a public client</li>
<li class="">Open your browser</li>
<li class="">Send you to Keycloak login</li>
<li class="">Show a consent prompt for <code>mcp:run</code></li>
<li class="">Redirect back to VS Code</li>
<li class="">Retry the MCP request with a bearer token</li>
</ol>
<p>If all the values are aligned, the server will accept the token and VS Code will discover the three tools.</p>
<p>At that point you can try a prompt such as:</p>
<div class="language-text codeBlockContainer_Z3mN theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_pizs"><pre tabindex="0" class="prism-code language-text codeBlock_iQew thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_ce1h"><div class="token-line" style="color:#bfc7d5"><span class="token plain">Use calculator-local to add 12.5 and 7.25, then multiply the result by 3.</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-to-do-if-it-still-fails">What to do if it still fails<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#what-to-do-if-it-still-fails" class="hash-link" aria-label="Direct link to What to do if it still fails" title="Direct link to What to do if it still fails" translate="no">​</a></h2>
<p>If the browser flow works but the MCP server still responds with <code>401</code>, the next thing to check is the token itself.</p>
<p>In practice, there are only three claims that matter most here:</p>
<ul>
<li class=""><code>iss</code></li>
<li class=""><code>aud</code></li>
<li class=""><code>scope</code></li>
</ul>
<p>Your server expects:</p>
<ul>
<li class=""><code>iss = http://localhost:8080/realms/mcp-demo</code></li>
<li class=""><code>aud = http://localhost:8000/mcp</code></li>
<li class=""><code>scope</code> contains <code>mcp:run</code></li>
</ul>
<p>The most common problems are:</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="aud-is-wrong"><code>aud</code> is wrong<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#aud-is-wrong" class="hash-link" aria-label="Direct link to aud-is-wrong" title="Direct link to aud-is-wrong" translate="no">​</a></h3>
<p>The Audience mapper is missing or its custom audience value does not exactly match the MCP URL.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="scope-is-empty"><code>scope</code> is empty<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#scope-is-empty" class="hash-link" aria-label="Direct link to scope-is-empty" title="Direct link to scope-is-empty" translate="no">​</a></h3>
<p><code>mcp:run</code> exists as a client scope, but <code>Include in token scope</code> is still off.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="dcr-fails-before-login">DCR fails before login<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#dcr-fails-before-login" class="hash-link" aria-label="Direct link to DCR fails before login" title="Direct link to DCR fails before login" translate="no">​</a></h3>
<p>The anonymous client registration policies still block VS Code's DCR payload. The first place to check is whether Trusted Hosts was actually removed.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="vs-code-behaves-inconsistently">VS Code behaves inconsistently<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#vs-code-behaves-inconsistently" class="hash-link" aria-label="Direct link to VS Code behaves inconsistently" title="Direct link to VS Code behaves inconsistently" translate="no">​</a></h3>
<p>Clear stale auth state with:</p>
<ul>
<li class=""><code>Authentication: Remove Dynamic Authentication Providers</code></li>
</ul>
<p>Then retry.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="a-few-implementation-notes-worth-remembering">A few implementation notes worth remembering<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#a-few-implementation-notes-worth-remembering" class="hash-link" aria-label="Direct link to A few implementation notes worth remembering" title="Direct link to A few implementation notes worth remembering" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="the-mcp-server-is-not-the-identity-provider">The MCP server is not the identity provider<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#the-mcp-server-is-not-the-identity-provider" class="hash-link" aria-label="Direct link to The MCP server is not the identity provider" title="Direct link to The MCP server is not the identity provider" translate="no">​</a></h3>
<p>Your MCP server does not need to implement a full OAuth server. It only needs to behave like a protected resource:</p>
<ul>
<li class="">Advertise protected-resource metadata</li>
<li class="">Challenge unauthenticated requests with <code>401</code></li>
<li class="">Validate bearer tokens from Keycloak</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="vs-code-cares-about-the-metadata-not-just-the-token-endpoint">VS Code cares about the metadata, not just the token endpoint<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#vs-code-cares-about-the-metadata-not-just-the-token-endpoint" class="hash-link" aria-label="Direct link to VS Code cares about the metadata, not just the token endpoint" title="Direct link to VS Code cares about the metadata, not just the token endpoint" translate="no">​</a></h3>
<p>If the MCP endpoint does not return the right <code>401</code> challenge, or the protected-resource metadata does not point at the Keycloak realm correctly, VS Code will never get to DCR.</p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="audience-matching-is-exact">Audience matching is exact<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#audience-matching-is-exact" class="hash-link" aria-label="Direct link to Audience matching is exact" title="Direct link to Audience matching is exact" translate="no">​</a></h3>
<p><code>http://localhost:8000/mcp</code> is not the same as <code>http://localhost:8000/mcp/</code> or <code>http://localhost:8000/</code>.</p>
<p>Use the exact same value everywhere:</p>
<ul>
<li class="">In VS Code</li>
<li class="">In the server</li>
<li class="">In Keycloak's Audience mapper</li>
<li class="">In the protected-resource metadata</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="final-checklist">Final checklist<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#final-checklist" class="hash-link" aria-label="Direct link to Final checklist" title="Direct link to Final checklist" translate="no">​</a></h2>
<p>Before you call this done, make sure all of these match:</p>
<ul>
<li class="">VS Code MCP URL: <code>http://localhost:8000/mcp</code></li>
<li class="">Protected-resource metadata <code>resource</code>: <code>http://localhost:8000/mcp</code></li>
<li class="">Keycloak audience mapper custom audience: <code>http://localhost:8000/mcp</code></li>
<li class="">Python <code>MCP_SERVER_URL</code>: <code>http://localhost:8000/mcp</code></li>
<li class="">Required scope: <code>mcp:run</code></li>
<li class=""><code>mcp:run</code> has <code>Include in token scope</code> enabled</li>
<li class=""><code>mcp:run</code> is available to DCR-created clients</li>
<li class="">Anonymous Trusted Hosts policy is removed</li>
</ul>
<p>If those are true, you have a clean local setup for a Keycloak-protected MCP server that VS Code can discover, register against, and use.</p>
<p><img decoding="async" loading="lazy" alt="MCP Keycloak Dataflow" src="https://phasetwo.io/assets/images/dataflow-bb6b6541403751224ddd6744e6bf5877.png" width="3333" height="4374" class="img_R7yg"></p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="why-phase-two-is-the-easiest-way-to-run-this-for-real">Why Phase Two is the easiest way to run this for real<a href="https://phasetwo.io/blog/instant-mcp-authorization-keycloak/#why-phase-two-is-the-easiest-way-to-run-this-for-real" class="hash-link" aria-label="Direct link to Why Phase Two is the easiest way to run this for real" title="Direct link to Why Phase Two is the easiest way to run this for real" translate="no">​</a></h2>
<p>If you like this pattern but do not want to spend time standing up and operating Keycloak, Phase Two's hosted Keycloak offering is the fastest way to get there. You get a managed realm, a production-ready control plane, and a much shorter path from prototype to deployed MCP authorization server.</p>
<p>You can start with a Starter cluster in the <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Phase Two Dashboard</a> — free for 30 days — for testing, and when you are ready for a hosted setup with support, upgrades, and operational help, Phase Two's managed Keycloak offering is the easiest way to run an MCP authorization server without owning the infrastructure yourself.</p>]]></content:encoded>
            <category>phase_two</category>
            <category>keycloak</category>
            <category>mcp</category>
            <category>authorization</category>
            <category>vscode</category>
            <category>tutorial</category>
            <category>open_source</category>
        </item>
        <item>
            <title><![CDATA[Phase Two Achieves ISO/IEC 27001 Certification]]></title>
            <link>https://phasetwo.io/blog/iso-27001-certification/</link>
            <guid>https://phasetwo.io/blog/iso-27001-certification/</guid>
            <pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Phase Two has achieved full ISO/IEC 27001 certification, strengthening our information security management program just over six months after our September 17, 2025 SOC 2 Type II compliance milestone.]]></description>
            <content:encoded><![CDATA[<p>Phase Two is excited to announce that we are now <strong>ISO/IEC 27001 certified</strong>.</p>
<p>This milestone reflects how seriously we take security and compliance across our platform, operations, and internal processes. We completed this as a fast follow to our <strong>September 17, 2025 <a class="" href="https://phasetwo.io/blog/soc-2-type-II-compliance/">SOC 2 Type II compliance milestone</a></strong>, reaching full ISO/IEC 27001 certification just over six months later as part of our commitment to building a mature, enterprise-ready security program.</p>
<p>Learn more at our Trust Center: <strong><a href="https://trust.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">trust.phasetwo.io</a></strong>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-is-isoiec-27001-certification">What is ISO/IEC 27001 certification?<a href="https://phasetwo.io/blog/iso-27001-certification/#what-is-isoiec-27001-certification" class="hash-link" aria-label="Direct link to What is ISO/IEC 27001 certification?" title="Direct link to What is ISO/IEC 27001 certification?" translate="no">​</a></h2>
<p>ISO/IEC 27001 is an internationally recognized standard for establishing, implementing, maintaining, and continually improving an <strong>Information Security Management System (ISMS)</strong>.</p>
<p>In practical terms, it provides a structured framework for identifying risk, applying appropriate controls, documenting security processes, and continuously improving how an organization protects systems and data. Full certification means this program has been evaluated against the standard rather than being described only as an internal compliance effort.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="why-this-matters">Why this matters<a href="https://phasetwo.io/blog/iso-27001-certification/#why-this-matters" class="hash-link" aria-label="Direct link to Why this matters" title="Direct link to Why this matters" translate="no">​</a></h2>
<ul>
<li class=""><strong>Structured security management:</strong> ISO/IEC 27001 formalizes how security risks are identified, assessed, treated, and reviewed over time.</li>
<li class=""><strong>Independent confidence:</strong> Certification against a globally recognized standard gives customers and prospects greater confidence in our security posture.</li>
<li class=""><strong>Faster vendor reviews:</strong> A stronger compliance foundation helps reduce friction during procurement, security questionnaires, and customer audits.</li>
<li class=""><strong>Continuous improvement:</strong> ISO/IEC 27001 is designed around ongoing review and maturation, not a one-time checkpoint.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="a-fast-follow-after-soc-2-type-ii">A Fast Follow After SOC 2 Type II<a href="https://phasetwo.io/blog/iso-27001-certification/#a-fast-follow-after-soc-2-type-ii" class="hash-link" aria-label="Direct link to A Fast Follow After SOC 2 Type II" title="Direct link to A Fast Follow After SOC 2 Type II" translate="no">​</a></h2>
<p>We take security and compliance very seriously at Phase Two. Achieving full ISO/IEC 27001 certification just over six months after our September 17, 2025 SOC 2 Type II compliance milestone was an intentional investment in operational maturity, not a box-checking exercise.</p>
<p>This fast follow reflects the pace at which we have continued to strengthen our internal security program, including risk management, policy development, operational controls, documented procedures, and accountability across the organization.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-this-means-for-customers">What this means for customers<a href="https://phasetwo.io/blog/iso-27001-certification/#what-this-means-for-customers" class="hash-link" aria-label="Direct link to What this means for customers" title="Direct link to What this means for customers" translate="no">​</a></h2>
<ul>
<li class=""><strong>Higher confidence in our processes:</strong> Our security program is built around a clear, repeatable framework for managing information security.</li>
<li class=""><strong>Better support for enterprise requirements:</strong> Customers with formal security review processes benefit from a stronger compliance posture and clearer documentation.</li>
<li class=""><strong>Ongoing resilience:</strong> A mature ISMS helps support secure delivery, reliable operations, and a disciplined response to evolving risks.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="whats-next">What’s next<a href="https://phasetwo.io/blog/iso-27001-certification/#whats-next" class="hash-link" aria-label="Direct link to What’s next" title="Direct link to What’s next" translate="no">​</a></h2>
<p>Security and compliance are continuous commitments. We will keep investing in the controls, processes, and operational rigor required to support customers with demanding security and compliance expectations.</p>
<p>Phase Two’s <strong><a class="" href="https://phasetwo.io/hosting/">hosting</a></strong> product continues to support organizations across healthcare, security, retail, public sector, and more. Milestones like ISO/IEC 27001 certification help us meet the expectations of customers who need strong identity infrastructure backed by strong operational discipline.</p>
<p>Thank you for trusting Phase Two with your business. If you would like to learn more, please visit <strong><a href="https://trust.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">trust.phasetwo.io</a></strong> or email <strong><a href="mailto:sales@phasetwo.io" target="_blank" rel="noopener noreferrer" class="">sales@phasetwo.io</a></strong>.</p>]]></content:encoded>
            <category>phase_two</category>
            <category>security</category>
            <category>hosting</category>
            <category>iso-27001</category>
        </item>
        <item>
            <title><![CDATA[Replacing Keycloak's Infinispan Caches with Redis/Valkey (Keycloak DevDay 2026)]]></title>
            <link>https://phasetwo.io/blog/keycloak-devday-2026-redis-valkey-caches/</link>
            <guid>https://phasetwo.io/blog/keycloak-devday-2026-redis-valkey-caches/</guid>
            <pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A practical overview of our work to replace Keycloak distributed Infinispan caches with Redis/Valkey, including architecture, implementation patterns, and benchmark outcomes.]]></description>
            <content:encoded><![CDATA[<p>At Keycloak DevDay 2026, we shared our work on replacing Keycloak's distributed <a href="https://infinispan.org/" target="_blank" rel="noopener noreferrer" class="">Infinispan</a> caches with <a href="https://redis.io/" target="_blank" rel="noopener noreferrer" class="">Redis</a>/<a href="https://valkey.io/" target="_blank" rel="noopener noreferrer" class="">Valkey</a>.</p>
<p>For the full technical deep dive, we will release slides when the talk is published on Youtube.</p>
<p>This post focuses on the core technical content from the presentation and summarizes what we built, what we learned, and what comes next.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="watch-the-talk">Watch the talk<a href="https://phasetwo.io/blog/keycloak-devday-2026-redis-valkey-caches/#watch-the-talk" class="hash-link" aria-label="Direct link to Watch the talk" title="Direct link to Watch the talk" translate="no">​</a></h2>
<iframe width="560" height="315" src="https://www.youtube.com/embed/rHeOQejcCQI?si=9QvaQ1JvUBTb7E-k" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin"></iframe>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="why-we-took-this-on">Why We Took This On<a href="https://phasetwo.io/blog/keycloak-devday-2026-redis-valkey-caches/#why-we-took-this-on" class="hash-link" aria-label="Direct link to Why We Took This On" title="Direct link to Why We Took This On" translate="no">​</a></h2>
<p>The recurring operator pain points were clear: restart complexity, JGroups/Infinispan operational friction, and upgrade risk in high-availability environments.</p>
<p>Our goals for the proof-of-concept were practical:</p>
<ol>
<li class="">Functional parity with Keycloak's distributed cache behavior.</li>
<li class="">At least 50% of embedded Infinispan performance.</li>
<li class="">Fast startup (sub-5s per pod) with reliable behavior.</li>
<li class="">Seamless upgrades and horizontal scaling without rebalance pain.</li>
<li class="">Extension-based implementation on current Keycloak, without forking.</li>
</ol>
<p>We also set explicit <strong>non-goals</strong>, including replacing local Infinispan caches, integrating/migrating persistent sessions, supporting every Redis topology, and solving multi-region concerns in v1.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="what-we-implemented-high-level">What We Implemented (High Level)<a href="https://phasetwo.io/blog/keycloak-devday-2026-redis-valkey-caches/#what-we-implemented-high-level" class="hash-link" aria-label="Direct link to What We Implemented (High Level)" title="Direct link to What We Implemented (High Level)" translate="no">​</a></h2>
<p>We implemented a new <a href="https://www.keycloak.org/docs-api/latest/javadocs/org/keycloak/storage/DatastoreProvider.html" target="_blank" rel="noopener noreferrer" class=""><code>DatastoreProvider</code></a>-based approach to replace distributed caches while leaving the rest of Keycloak behavior intact.</p>
<p>At a high level:</p>
<ol>
<li class="">Stored entities as Redis hashes (session and related objects).</li>
<li class="">Added secondary indexes with Redis sets for fast lookup paths (for example, user-to-sessions, client-to-sessions).</li>
<li class="">Built a changelog-based transaction implementation of <a href="https://www.keycloak.org/docs-api/latest/javadocs/org/keycloak/models/KeycloakTransaction.html" target="_blank" rel="noopener noreferrer" class=""><code>KeycloakTransaction</code></a> to batch and minimize writes at the appropriate stage of the Keycloak request lifecycle.</li>
<li class="">Used Redis <code>MULTI/EXEC</code> for batched commit behavior.</li>
<li class="">Implemented Lua-based CAS behavior for single-round-trip atomic updates.</li>
<li class="">Replaced the use of the replicated work cache, used for local cache invalidation, with a Redis PUBSUB-backed <code>ClusterProvider</code>.</li>
<li class="">Exposed Redis client and command metrics in Keycloak's Prometheus endpoint (<code>vendor_jedis_*</code>).</li>
</ol>
<p>For detailed APIs, object mapping rules, and transaction semantics, the slide deck has the full implementation flow and diagrams.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="notable-engineering-details">Notable Engineering Details<a href="https://phasetwo.io/blog/keycloak-devday-2026-redis-valkey-caches/#notable-engineering-details" class="hash-link" aria-label="Direct link to Notable Engineering Details" title="Direct link to Notable Engineering Details" translate="no">​</a></h2>
<p>A few areas mattered most in practice:</p>
<ol>
<li class=""><strong>Atomicity model:</strong> Lua CAS operations reduced contention and retry complexity versus <code>WATCH</code>-based optimistic workflows.</li>
<li class=""><strong>Expiration correctness:</strong> TTL handling required careful validation to avoid stale references (for example, refresh token edge cases).</li>
<li class=""><strong>Behavior parity:</strong> Reproducing nuanced Infinispan behavior (especially around offline session flows and invalidation semantics) required close reading of existing Keycloak internals.</li>
<li class=""><strong>Index discipline:</strong> Clean separation between hash storage and set-based secondary indexes kept lookups efficient and made invalidation predictable.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="benchmarks-and-outcomes">Benchmarks and Outcomes<a href="https://phasetwo.io/blog/keycloak-devday-2026-redis-valkey-caches/#benchmarks-and-outcomes" class="hash-link" aria-label="Direct link to Benchmarks and Outcomes" title="Direct link to Benchmarks and Outcomes" translate="no">​</a></h2>
<p>In our benchmark setup, the Redis implementation met and exceeded the original performance target. The result was parity or better across the tested flow, while also meeting the startup-speed and extension/no-fork constraints. We used the Keycloak provided <a href="https://github.com/keycloak/keycloak-benchmark" target="_blank" rel="noopener noreferrer" class="">keycloak-benchmark</a> project to execute performance tests against a 3 node cluster using Redis caching, and compared the results to a standard Keycloak setup on the same number of nodes, using embedded Infinispan.</p>
<p>This approach hit the proof-of-concept goals, with room for additional optimization and broader validation.</p>
<div><table class="table w-full border-collapse bg-white/10 text-sm"><thead class="bg-white/10"><tr><th class="px-4 py-3 text-left font-semibold text-white"><p>Benchmark</p></th><th class="px-4 py-3 text-right font-semibold text-white"><p>Infinispan (99th pct)</p></th><th class="px-4 py-3 text-right font-semibold text-white"><p>Redis (99th pct)</p></th><th class="px-4 py-3 text-right font-semibold text-white">Delta</th></tr></thead><tbody class="divide-y divide-white/15"><tr><td class="px-4 py-3 text-white">All Requests</td><td class="px-4 py-3 text-right text-gray-200">67</td><td class="px-4 py-3 text-right text-gray-200">47</td><td class="px-4 py-3 text-right"><span class="font-semibold text-green-400">29.9%</span></td></tr><tr><td class="px-4 py-3 text-white">Browser to Log In Endpoint</td><td class="px-4 py-3 text-right text-gray-200">71</td><td class="px-4 py-3 text-right text-gray-200">86</td><td class="px-4 py-3 text-right"><span class="font-semibold text-red-400">-21.1%</span></td></tr><tr><td class="px-4 py-3 text-white"><p>Browser posts correct credentials</p></td><td class="px-4 py-3 text-right text-gray-200">83</td><td class="px-4 py-3 text-right text-gray-200">57</td><td class="px-4 py-3 text-right"><span class="font-semibold text-green-400">31.3%</span></td></tr><tr><td class="px-4 py-3 text-white">Exchange Code</td><td class="px-4 py-3 text-right text-gray-200">22</td><td class="px-4 py-3 text-right text-gray-200">10</td><td class="px-4 py-3 text-right"><span class="font-semibold text-green-400">54.5%</span></td></tr><tr><td class="px-4 py-3 text-white">RefreshToken</td><td class="px-4 py-3 text-right text-gray-200">36</td><td class="px-4 py-3 text-right text-gray-200">12</td><td class="px-4 py-3 text-right"><span class="font-semibold text-green-400">66.7%</span></td></tr><tr><td class="px-4 py-3 text-white">Browser logout</td><td class="px-4 py-3 text-right text-gray-200">43</td><td class="px-4 py-3 text-right text-gray-200">9</td><td class="px-4 py-3 text-right"><span class="font-semibold text-green-400">79.1%</span></td></tr></tbody></table></div>
<p><em>Note: lower 99th percentile values are better. The "Browser to Log In Endpoint" row is the one regression shown in the original chart.</em></p>
<h3 class="anchor anchorTargetStickyNavbar_Sc0N" id="benchmark-screenshots">Benchmark screenshots<a href="https://phasetwo.io/blog/keycloak-devday-2026-redis-valkey-caches/#benchmark-screenshots" class="hash-link" aria-label="Direct link to Benchmark screenshots" title="Direct link to Benchmark screenshots" translate="no">​</a></h3>
<p>Redis benchmark test results:</p>
<p><img decoding="async" loading="lazy" alt="Redis benchmark test results" src="https://phasetwo.io/assets/images/redis-test-results-cd826d293dfa9febc7ea3e989facc73b.png" width="2692" height="592" class="img_R7yg"></p>
<p>Infinispan benchmark test results:</p>
<p><img decoding="async" loading="lazy" alt="Infinispan benchmark test results" src="https://phasetwo.io/assets/images/infinispan-test-results-5af855541eda5217206c39ce9c04864d.png" width="2710" height="590" class="img_R7yg"></p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="timeline-and-context">Timeline and Context<a href="https://phasetwo.io/blog/keycloak-devday-2026-redis-valkey-caches/#timeline-and-context" class="hash-link" aria-label="Direct link to Timeline and Context" title="Direct link to Timeline and Context" translate="no">​</a></h2>
<p>This work started in <strong>September 2025</strong>.</p>
<p>A successful implementation would not have been possible without years of prior work in Keycloak internals, extension development, and production operations. Existing knowledge around map-store patterns, DatastoreProvider internals, transaction behavior, and real-world customer requirements was the foundation that made this tractable.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="whats-next">What's Next<a href="https://phasetwo.io/blog/keycloak-devday-2026-redis-valkey-caches/#whats-next" class="hash-link" aria-label="Direct link to What's Next" title="Direct link to What's Next" translate="no">​</a></h2>
<p>Near-term next steps include broader performance validation across workloads, clearer packaging/licensing for source and images, and additional multi-region validation/documentation.</p>
<p>We also plan to release a full image soon at <a href="https://quay.io/repository/phasetwo/keycloak-redis" target="_blank" rel="noopener noreferrer" class="">quay.io/repository/phasetwo/keycloak-redis</a>, so stay tuned for more.</p>
<p>If you want all the implementation specifics, design tradeoffs, and diagrams, please stay tuned for the release of the slides and videos of our Keycloak DevDay presentation.</p>
<hr>
<p>Want to go deeper?</p>
<ol>
<li class="">Reach out to our team: <a href="mailto:support@phasetwo.io" target="_blank" rel="noopener noreferrer" class="">support@phasetwo.io</a></li>
<li class="">Try our hosting and extensions: <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Phase Two Dash</a></li>
<li class="">Contact us for additional implementation details and architecture guidance: <a href="https://phasetwo.io/contact" target="_blank" rel="noopener noreferrer" class="">https://phasetwo.io/contact</a></li>
</ol>]]></content:encoded>
            <category>phase_two</category>
            <category>keycloak</category>
            <category>redis</category>
            <category>valkey</category>
            <category>caching</category>
            <category>performance</category>
            <category>extensions</category>
        </item>
        <item>
            <title><![CDATA[Cluster Observability and Logs]]></title>
            <link>https://phasetwo.io/blog/cluster-observability-and-logs/</link>
            <guid>https://phasetwo.io/blog/cluster-observability-and-logs/</guid>
            <pubDate>Wed, 04 Feb 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Phase Two now includes built-in observability for dedicated clusters, starting with log downloads via the self-service dashboard.]]></description>
            <content:encoded><![CDATA[<p>We’re excited to announce built-in observability for dedicated clusters, starting with <strong>log downloads</strong> in the Phase Two dashboard.</p>
<p>Access to logs is essential for monitoring health, diagnosing incidents, and understanding performance. With this initial release, you can filter logs by time range and download them as a zip file for offline analysis. This helps teams quickly pinpoint issues and keep their services running smoothly.</p>
<p>Next up: <strong>live log streaming</strong> and <strong>exports to external systems</strong>, giving you more flexibility for centralized log management and analysis.</p>
<p>Learn more in the <a href="https://phasetwo.io/docs/self-service/observability/" target="_blank" rel="noopener noreferrer" class="">Observability and Logs documentation</a>.</p>
<p>Ready to try it? Log in to the <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Phase Two Dash</a> and view logs for your cluster. This feature is included with all <strong>dedicated cluster plans</strong>. Questions? Reach us at <a href="mailto:support@phasetwo.io" target="_blank" rel="noopener noreferrer" class="">support@phasetwo.io</a>.</p>]]></content:encoded>
            <category>phase_two</category>
            <category>hosting</category>
            <category>open_source</category>
            <category>keycloak</category>
            <category>observability</category>
            <category>logs</category>
        </item>
        <item>
            <title><![CDATA[Cockroach Labs features Phase Two's Managed Keycloak Hosting]]></title>
            <link>https://phasetwo.io/blog/cockroach-labs-features-phasetwo-managed-keycloak-hosting/</link>
            <guid>https://phasetwo.io/blog/cockroach-labs-features-phasetwo-managed-keycloak-hosting/</guid>
            <pubDate>Wed, 14 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Cockroach Labs highlights Phase Two's managed Keycloak hosting platform in their latest blog post.]]></description>
            <content:encoded><![CDATA[<p>We’re pleased to share that Cockroach Labs has published a new blog post featuring Phase Two and our managed Keycloak hosting platform.</p>
<p>In <a href="https://www.cockroachlabs.com/blog/deploying-keycloak-on-cockroachdb-with-phase-two/" target="_blank" rel="noopener noreferrer" class=""><em>Deploying Keycloak on CockroachDB with Phase Two: A Complete Guide</em></a>, Cockroach Labs walks through how Phase Two and CockroachDB work together to provide a strong foundation for secure, multi-tenant identity at scale. The post outlines why strong consistency, high availability, and horizontal scalability are critical requirements for modern SaaS identity systems—and how combining managed Keycloak with CockroachDB meets those needs in production environments.</p>
<p>The article also includes an overview of Phase Two’s managed Keycloak offering, highlighting how teams can offload operational complexity such as infrastructure management, upgrades, backups, and high-availability configuration. By pairing this with CockroachDB’s globally distributed SQL database, organizations can deploy enterprise-grade authentication and SSO while maintaining strong consistency and predictable performance.</p>
<p>We appreciate Cockroach Labs’ collaboration and the opportunity to showcase how this complementary stack helps SaaS teams move faster without compromising on reliability or security.</p>
<p>You can read the full post on the Cockroach Labs blog here:
<a href="https://www.cockroachlabs.com/blog/deploying-keycloak-on-cockroachdb-with-phase-two/" target="_blank" rel="noopener noreferrer" class=""><strong>Deploying Keycloak on CockroachDB with Phase Two: A Complete Guide</strong></a></p>]]></content:encoded>
            <category>phase_two</category>
            <category>hosting</category>
            <category>open_source</category>
            <category>keycloak</category>
            <category>cockroachdb</category>
        </item>
        <item>
            <title><![CDATA[Security Capabilities Now Available for Keycloak Clusters]]></title>
            <link>https://phasetwo.io/blog/dedicated-clusters-security-capabilities/</link>
            <guid>https://phasetwo.io/blog/dedicated-clusters-security-capabilities/</guid>
            <pubDate>Mon, 05 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Enhance the security of your Keycloak clusters with new features available on the Phase Two Dash.]]></description>
            <content:encoded><![CDATA[<p>At Phase Two, we are committed to providing our customers with the most secure and reliable managed Keycloak hosting platform. As part of this commitment, we are excited to announce the release of new security capabilities for Keycloak clusters available through the Phase Two Dash.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="new-security-features">New Security Features<a href="https://phasetwo.io/blog/dedicated-clusters-security-capabilities/#new-security-features" class="hash-link" aria-label="Direct link to New Security Features" title="Direct link to New Security Features" translate="no">​</a></h2>
<p>We have introduced the first of several new security features that can be easily configured through the Phase Two Dash. These features are designed to enhance the security posture of your Keycloak clusters and provide you with greater control over your authentication environment.</p>
<p>The initial set of security features includes the ability to configure <strong>IP level access</strong> to your Keycloak clusters. There are three types of IP restrictions you can apply:</p>
<ol>
<li class=""><strong>Admin Paths</strong>: Restrict access to the administrative interfaces of your Keycloak instance (e.g., /admin, /auth/admin) to specific IP addresses or ranges. This ensures that only authorized personnel can access sensitive admin functions.</li>
<li class=""><strong>Public Allowed Paths</strong>: Specify IP addresses that are allowed to access public endpoints (e.g., /token). This is useful for limiting access to known clients while blocking all other traffic. Particularly useful for development and staging environments.</li>
<li class=""><strong>Public Blocked Paths</strong>: Block specific IP addresses from accessing public endpoints (e.g., /token). This allows you to prevent known malicious IPs from interacting with your Keycloak instance without affecting legitimate traffic. Or to just block abuse to your application for any reason.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="how-to-configure-security-features">How to Configure Security Features<a href="https://phasetwo.io/blog/dedicated-clusters-security-capabilities/#how-to-configure-security-features" class="hash-link" aria-label="Direct link to How to Configure Security Features" title="Direct link to How to Configure Security Features" translate="no">​</a></h2>
<p>Visit your cluster's page &gt; Config tab &gt; Restrictions to manage IP restrictions for your Keycloak cluster. You can easily add or remove IP addresses or CIDR ranges for each type of restriction.</p>
<p>Learn more in the <a href="https://phasetwo.io/docs/self-service/restrictions/" target="_blank" rel="noopener noreferrer" class="">Cluster Restrictions documentation</a>.</p>
<hr>
<p>Want to learn more or have feedback? Log in to the <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Phase Two Dash</a> and try adding your IP restriction now. This feature is included with all <strong>dedicated cluster plans</strong>. For any questions or concerns, please reach out to <a href="mailto:support@phasetwo.io" target="_blank" rel="noopener noreferrer" class="">support@phasetwo.io</a>.</p>]]></content:encoded>
            <category>phase_two</category>
            <category>hosting</category>
            <category>open_source</category>
            <category>keycloak</category>
            <category>security</category>
        </item>
        <item>
            <title><![CDATA[Configure Environment Variables for Dedicated Keycloak Clusters]]></title>
            <link>https://phasetwo.io/blog/dedicated-clusters-environment-variables/</link>
            <guid>https://phasetwo.io/blog/dedicated-clusters-environment-variables/</guid>
            <pubDate>Mon, 29 Dec 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[How to set environment variables for dedicated Keycloak clusters on the Phase Two Dash]]></description>
            <content:encoded><![CDATA[<p>Phase Two has been storming ahead with our managed Keycloak hosting platform, <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">dash.phasetwo.io</a>. As part of our commitment to providing flexible and powerful hosting solutions, we are excited to announce that users can now set environment variables for their dedicated Keycloak clusters directly through the Phase Two Dash.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="why-environment-variables">Why Environment Variables?<a href="https://phasetwo.io/blog/dedicated-clusters-environment-variables/#why-environment-variables" class="hash-link" aria-label="Direct link to Why Environment Variables?" title="Direct link to Why Environment Variables?" translate="no">​</a></h2>
<p>Environment variables are a crucial aspect of configuring and managing applications. They allow you to customize the behavior of your Keycloak instances without modifying the underlying code or configuration files. This is especially important for managing sensitive information such as API keys and other configuration settings. For customers building custom Keycloak extensions or integrating with third-party services, the ability to set environment variables is essential.</p>
<p>With this new feature, you can easily manage your Keycloak cluster's environment variables through a user-friendly interface, streamlining the deployment and configuration process. Updates are applied seamlessly, requiring no downtime for your authentication services.</p>
<h2 class="anchor anchorTargetStickyNavbar_Sc0N" id="how-to-set-environment-variables">How to Set Environment Variables<a href="https://phasetwo.io/blog/dedicated-clusters-environment-variables/#how-to-set-environment-variables" class="hash-link" aria-label="Direct link to How to Set Environment Variables" title="Direct link to How to Set Environment Variables" translate="no">​</a></h2>
<p>Within your cluster, visit your cluster's page &gt; Config tab &gt; Keycloak features. From there, you can add new environment variables or remove existing ones. You have the option to set variables as plaintext or as secrets, ensuring that sensitive information remains secure.</p>
<p>Learn more in the <a href="https://phasetwo.io/docs/self-service/environment-variables/" target="_blank" rel="noopener noreferrer" class="">Environment Variables documentation</a>.</p>
<hr>
<p>Want to learn more or have feedback? Log in to the <a href="https://dash.phasetwo.io/" target="_blank" rel="noopener noreferrer" class="">Phase Two Dash</a> and try adding your first environment variable. This feature is included with all <strong>dedicated cluster plans</strong>. For any questions or concerns, please reach out to <a href="mailto:support@phasetwo.io" target="_blank" rel="noopener noreferrer" class="">support@phasetwo.io</a>.</p>]]></content:encoded>
            <category>phase_two</category>
            <category>hosting</category>
            <category>open_source</category>
            <category>keycloak</category>
        </item>
    </channel>
</rss>