<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Edmilson Santana</title>
    <description>The latest articles on DEV Community by Edmilson Santana (@edmilsonss).</description>
    <link>https://dev.to/edmilsonss</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1400778%2Fc860371c-258c-488d-be72-204cfe3573f9.png</url>
      <title>DEV Community: Edmilson Santana</title>
      <link>https://dev.to/edmilsonss</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/edmilsonss"/>
    <language>en</language>
    <item>
      <title>The Keycloak Admin API from TypeScript, without the surprises</title>
      <dc:creator>Edmilson Santana</dc:creator>
      <pubDate>Fri, 16 Oct 2026 16:33:40 +0000</pubDate>
      <link>https://dev.to/edmilsonss/the-keycloak-admin-api-from-typescript-without-the-surprises-nnk</link>
      <guid>https://dev.to/edmilsonss/the-keycloak-admin-api-from-typescript-without-the-surprises-nnk</guid>
      <description>&lt;p&gt;If you automate Keycloak from Node.js (creating users during onboarding, provisioning clients per tenant, syncing groups from HR), you eventually hit the same set of friction points. I wrote &lt;strong&gt;keycloak-api-ts&lt;/strong&gt; to remove the ones that bothered me most, and version 2.0 is out: it covers &lt;strong&gt;100% of the Keycloak 26.8.1 Admin API&lt;/strong&gt; (415 operations) and is tested against real 24, 25 and 26 servers on every pull request.&lt;/p&gt;

&lt;p&gt;This post covers the problem, the solution and the trade-offs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The official client, &lt;code&gt;@keycloak/keycloak-admin-client&lt;/code&gt;, is maintained by the Keycloak team and is the natural first choice. In everyday backend code, though (looking at 26.8.1), it comes with a few tolls:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It's ESM-only.&lt;/strong&gt; The package is &lt;code&gt;"type": "module"&lt;/code&gt; with no CommonJS build. On Node 20.19+ and 22.12+ &lt;code&gt;require()&lt;/code&gt; of ESM works, but Jest/ts-jest in CommonJS mode, older Node versions and some bundlers still stumble, and you end up with &lt;code&gt;transformIgnorePatterns&lt;/code&gt;, dynamic &lt;code&gt;await import()&lt;/code&gt; or a project migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. HTTP errors are thrown.&lt;/strong&gt; A missing user (404) or a duplicate username (409) isn't exceptional; it's business logic. Still, every non-2xx response throws a &lt;code&gt;NetworkError&lt;/code&gt; (only some &lt;code&gt;findOne&lt;/code&gt; calls return &lt;code&gt;null&lt;/code&gt; on 404), so you need a &lt;code&gt;try/catch&lt;/code&gt; and a look at &lt;code&gt;error.response.status&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// official client&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;kc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jdoe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 409? 403? network? you have to inspect the error to know&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. The default realm is global mutable state.&lt;/strong&gt; &lt;code&gt;kc.setConfig({ realmName: 'tenant-a' })&lt;/code&gt; changes the realm for every following call. You can override it per call by passing &lt;code&gt;realm&lt;/code&gt; in the payload, but that's opt-in: forget it once, and in concurrent multi-tenant code the call lands in whatever realm another request configured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Tokens renew only with a refresh token.&lt;/strong&gt; After &lt;code&gt;auth()&lt;/code&gt;, the client renews on its own when it has a refresh token. With &lt;code&gt;client_credentials&lt;/code&gt;, the typical service flow, Keycloak doesn't issue a refresh token by default (the client's "Use refresh tokens for client credentials grant" option is off), so once the access token expires calls fail with "Cannot refresh token". You either call &lt;code&gt;auth()&lt;/code&gt; again or register your own &lt;code&gt;TokenProvider&lt;/code&gt;. There's no retry on 401, no backoff retries and no rate limiting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Coverage is partial.&lt;/strong&gt; By my count on 26.8.1, the official client covers about 340 of the 415 operations in the OpenAPI spec (≈82%). Missing are, for example, role composites, groups and permissions by role name, authorization-services searches, client types, parts of organizations and workflows, localization, and the legacy client templates. For those, you're back to hand-rolled &lt;code&gt;fetch&lt;/code&gt; calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solution
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;keycloak-api-ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;KeycloakAPI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keycloak-api-ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// or require('keycloak-api-ts')&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;KeycloakAPI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://sso.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;grantType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;client_credentials&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin-service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;clientSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KEYCLOAK_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jdoe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;new user id:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// taken from the Location header&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user already exists&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Errors as values.&lt;/strong&gt; Every call resolves to &lt;code&gt;{ success, status, data | error }&lt;/code&gt;, a discriminated union: after &lt;code&gt;if (res.success)&lt;/code&gt;, TypeScript knows &lt;code&gt;data&lt;/code&gt; is there. The promise only rejects on network failures or when a token can't be obtained (&lt;code&gt;KeycloakAuthError&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realm per call.&lt;/strong&gt; &lt;code&gt;api.users.list('tenant-a')&lt;/code&gt; and &lt;code&gt;api.users.list('tenant-b')&lt;/code&gt; can run in parallel on the same instance without stepping on each other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic tokens.&lt;/strong&gt; Requested lazily, cached, and renewed before &lt;code&gt;expires_in&lt;/code&gt; (using the refresh token when there is one). A &lt;code&gt;401&lt;/code&gt; invalidates the token and retries the request once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resilience on the instance.&lt;/strong&gt; Exponential-backoff retries for network errors, 408, 429 and 5xx on idempotent requests, plus a client-side rate limit (10 req/s by default). All of it lives on the library's own axios instance, so the global axios of your app is never touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native CommonJS and ESM.&lt;/strong&gt; The package ships both builds, so &lt;code&gt;require&lt;/code&gt; and &lt;code&gt;import&lt;/code&gt; work on any Node 20+, Jest in CommonJS mode included, without relying on &lt;code&gt;require(esm)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Types generated from the official OpenAPI spec.&lt;/strong&gt; &lt;code&gt;User&lt;/code&gt;, &lt;code&gt;Client&lt;/code&gt;, &lt;code&gt;Realm&lt;/code&gt;, &lt;code&gt;Group&lt;/code&gt; and friends come straight from the Keycloak 26.8.1 spec, and the raw schemas are exported as &lt;code&gt;KeycloakSchemas&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;create&lt;/code&gt; returns the id.&lt;/strong&gt; Keycloak answers &lt;code&gt;201&lt;/code&gt; with the id in the &lt;code&gt;Location&lt;/code&gt; header; the library hands it to you in &lt;code&gt;data&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Things you only learn against a real server
&lt;/h2&gt;

&lt;p&gt;The OpenAPI spec doesn't tell the whole story. A few things only surfaced when the integration tests (Testcontainers running Keycloak in Docker) hit a real server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authorization policies must be created on &lt;code&gt;/policy/{type}&lt;/code&gt;; the generic endpoint in the spec can't parse provider-specific fields.&lt;/li&gt;
&lt;li&gt;Localization texts, credential labels and workflows use text or YAML bodies, not JSON.&lt;/li&gt;
&lt;li&gt;Keystore downloads come back as a &lt;code&gt;Buffer&lt;/code&gt;, and 24/25 require a &lt;code&gt;keyPassword&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Certificate uploads must be multipart.&lt;/li&gt;
&lt;li&gt;Array filters are sent as repeated query parameters (&lt;code&gt;type=LOGIN&amp;amp;type=LOGOUT&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these became a test, and CI runs the 24.0.5 / 25.0.6 / 26.8.1 matrix on every pull request.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to stick with the official client
&lt;/h2&gt;

&lt;p&gt;If your project is already ESM, always runs the latest Keycloak and you're happy with exceptions, the official client is a perfectly good choice backed by the Keycloak team. It also ships things keycloak-api-ts doesn't have yet: Shared Signals (SSF) and verifiable-credentials endpoints, which aren't even in the OpenAPI spec, and experimental access to the Admin API v2 for clients. keycloak-api-ts makes more sense when you have a CommonJS backend, multi-tenant code, servers on different versions (24 to 26), or you need endpoints the official client doesn't cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/keycloak-api-ts" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/keycloak-api-ts&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/Master-Mils/keycloak-api-ts" rel="noopener noreferrer"&gt;https://github.com/Master-Mils/keycloak-api-ts&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Issues, ideas and PRs are welcome. Next on the roadmap are the Admin API v2, once it's stable, and the SSF and verifiable-credentials endpoints. If you use Keycloak from Node, tell me in the comments what your biggest pain with the Admin API is.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>keycloak</category>
      <category>node</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
