<?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: Ismail ZAHIR</title>
    <description>The latest articles on DEV Community by Ismail ZAHIR (@ismailzahir).</description>
    <link>https://dev.to/ismailzahir</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%2F4047894%2Fd92e0840-d13b-44ec-93ad-cf01d8600704.jpg</url>
      <title>DEV Community: Ismail ZAHIR</title>
      <link>https://dev.to/ismailzahir</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ismailzahir"/>
    <language>en</language>
    <item>
      <title>Building a Reusable Keycloak Theme Architecture</title>
      <dc:creator>Ismail ZAHIR</dc:creator>
      <pubDate>Sat, 15 Aug 2026 22:14:39 +0000</pubDate>
      <link>https://dev.to/ismailzahir/building-a-reusable-keycloak-theme-architecture-1b9h</link>
      <guid>https://dev.to/ismailzahir/building-a-reusable-keycloak-theme-architecture-1b9h</guid>
      <description>&lt;p&gt;The last article in this series ended with a promise: make Keycloak stop looking like Keycloak. I kept it. The login pages got a custom template and stylesheet, and the roughly seventeen emails Keycloak sends got the same treatment.&lt;/p&gt;

&lt;p&gt;It worked. I wasn't satisfied.&lt;/p&gt;

&lt;p&gt;The login theme carried its own styles. The email theme carried its own resources. If I wanted a second branded version of the same authentication experience — another product, another client, another environment — I would have been copying files and hoping I remembered to update both.&lt;/p&gt;

&lt;p&gt;That turned a styling exercise into an engineering question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do you build Keycloak themes once, reuse the common parts, and still let every application customize what it actually needs?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is the answer I arrived at, and it is part of my &lt;strong&gt;Engineering in Practice&lt;/strong&gt; series, where I document the decisions and the reasoning behind real projects rather than the finished result alone.&lt;/p&gt;

&lt;p&gt;The implementation lives in &lt;a href="https://github.com/ismailza/keycloak-modern-auth" rel="noopener noreferrer"&gt;&lt;code&gt;keycloak-modern-auth&lt;/code&gt;&lt;/a&gt;, the companion repository that evolves alongside these articles.&lt;/p&gt;

&lt;h2&gt;
  
  
  From customization to architecture
&lt;/h2&gt;

&lt;p&gt;In the earlier work, the goal was simple: customize Keycloak. Replace the default login screen with a custom design, then do the same for the emails.&lt;/p&gt;

&lt;p&gt;That's perfectly adequate when you have one application and one visual identity. But real systems grow.&lt;/p&gt;

&lt;p&gt;You end up with multiple applications, multiple brands, several environments, or a few products sharing one identity provider — and each of them wants its own login experience and its own email branding.&lt;/p&gt;

&lt;p&gt;At that point, duplicating templates and CSS across themes stops being convenient and starts being a liability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;theme-a/
├── login/
│   ├── template.ftl
│   └── resources/css/login.css
└── email/
    ├── html/template.ftl
    └── messages/

theme-b/
├── login/
│   ├── template.ftl        ← same file, second copy
│   └── resources/css/login.css
└── email/
    ├── html/template.ftl
    └── messages/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At two themes this looks harmless.&lt;/p&gt;

&lt;p&gt;Then someone asks to change the border radius, or the input height, or the focus ring — and you have to remember every theme that contains a copy, and update them consistently.&lt;/p&gt;

&lt;p&gt;Duplication that started as convenience has become an architectural problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: a reusable base theme
&lt;/h2&gt;

&lt;p&gt;Instead of treating every Keycloak theme as an isolated implementation, I wanted a common layer underneath them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;               Base theme
                    │
         ┌──────────┴──────────┐
         │                     │
   Application A         Application B
         │                     │
     overrides             overrides
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The base theme holds the authentication design. Derived themes hold only what makes them different.&lt;/p&gt;

&lt;p&gt;This gives two properties that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reuse&lt;/strong&gt;, because the shared templates and stylesheets exist in exactly one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customization&lt;/strong&gt;, because a derived theme can still override the parts that genuinely need to change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's the same instinct as inheritance in code: put the common behaviour in the base, specialize where necessary.&lt;/p&gt;

&lt;p&gt;The interesting part is that Keycloak already gives you the mechanism — you just have to use it deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Keycloak actually resolves themes
&lt;/h2&gt;

&lt;p&gt;A theme declares its parent in &lt;code&gt;theme.properties&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;modern.base&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single line is doing more than it looks like, because Keycloak resolves the four kinds of theme content by four different rules — and knowing which is which is most of the battle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Properties merge vertically.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A child's &lt;code&gt;theme.properties&lt;/code&gt; is layered over its parent's. Anything the child doesn't set, it inherits. This is the mechanism that makes token overriding work at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Templates resolve child-first, and whole-file.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the child ships &lt;code&gt;template.ftl&lt;/code&gt;, its version is used entirely; if it doesn't, the parent's is used entirely. There is no partial override, no block merging.&lt;/p&gt;

&lt;p&gt;You inherit a template or you replace it — which is a good reason to keep base templates generic enough that nobody needs to replace them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resources resolve per file.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Drop a file at the same relative path and it shadows the parent's. This is how a child theme swaps &lt;code&gt;img/logo.svg&lt;/code&gt; without touching a single line of CSS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;import=&lt;/code&gt; reaches sideways.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Inheritance runs vertically, from child to parent, within one theme type.&lt;/p&gt;

&lt;p&gt;But a login theme and an email theme are different types, and both need the same brand colour. &lt;code&gt;import=&lt;/code&gt; connects them, so shared values can live in a &lt;code&gt;common&lt;/code&gt; theme that both types pull from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modern.base/login  ←import←  modern.base/common
       ↑ parent                      ↑ parent
    acme/login     ←import←       acme/common
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That diagram is the whole architecture.&lt;/p&gt;

&lt;p&gt;Vertical arrows are inheritance; horizontal arrows are sharing across types.&lt;/p&gt;

&lt;p&gt;In the repository, &lt;code&gt;modern.base&lt;/code&gt; is the foundation and &lt;code&gt;acme&lt;/code&gt; is a worked example of a child brand — one properties file and a logo.&lt;/p&gt;

&lt;p&gt;This is also why I extend Keycloak's theme system rather than editing the built-in themes: the built-ins move underneath you on every upgrade, and you own the diff forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What belongs in the base theme
&lt;/h2&gt;

&lt;p&gt;Getting the mechanism right is the easy half.&lt;/p&gt;

&lt;p&gt;The harder question is what should actually live in the base — because a base theme that accumulates every possible customization is just a large theme with extra steps.&lt;/p&gt;

&lt;p&gt;I settled on a simple rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The base owns structure, the child owns identity.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The base theme holds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;design tokens&lt;/li&gt;
&lt;li&gt;typography scale&lt;/li&gt;
&lt;li&gt;page layout&lt;/li&gt;
&lt;li&gt;form and input styling&lt;/li&gt;
&lt;li&gt;buttons&lt;/li&gt;
&lt;li&gt;alerts and messages&lt;/li&gt;
&lt;li&gt;shared assets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It answers the question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How does the authentication interface work and look by default?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The derived theme holds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;logo&lt;/li&gt;
&lt;li&gt;brand colours&lt;/li&gt;
&lt;li&gt;favicon&lt;/li&gt;
&lt;li&gt;product-specific assets&lt;/li&gt;
&lt;li&gt;any genuinely necessary override&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It answers a much narrower question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How does this particular product want to be recognized?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That boundary is what keeps the abstraction honest.&lt;/p&gt;

&lt;p&gt;If a child theme starts reaching for the structural layer, that's a signal the base is missing something — not a signal to fork the template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design tokens, and the loop that generates them
&lt;/h2&gt;

&lt;p&gt;The piece that makes the split practical is design tokens.&lt;/p&gt;

&lt;p&gt;Without them, values scatter through the stylesheet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.kc-button-primary&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4f46e5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.kc-input&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#d1d5db&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;Change the brand colour and you are grepping.&lt;/p&gt;

&lt;p&gt;With tokens, the values are declared once and consumed everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--kc-color-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4f46e5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--kc-color-primary-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4338ca&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--kc-color-surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--kc-color-text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#111827&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--kc-color-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#d1d5db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--kc-radius-md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--kc-space-md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.kc-button-primary&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--kc-color-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--kc-radius-md&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;The part I like, though, isn't the CSS.&lt;/p&gt;

&lt;p&gt;It's where the values come from.&lt;/p&gt;

&lt;p&gt;Rather than hand-writing that &lt;code&gt;:root&lt;/code&gt; block, the base template walks the theme's properties and emits a custom property for every key that starts with &lt;code&gt;kcToken&lt;/code&gt;, converting camelCase to kebab-case along the way.&lt;/p&gt;

&lt;p&gt;So this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;kcTokenColorPrimary&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="c"&gt;#0d9488
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;becomes this, at render time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--kc-color-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0d9488&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;The loop is generic.&lt;/p&gt;

&lt;p&gt;It enumerates properties; it doesn't know their names.&lt;/p&gt;

&lt;p&gt;That detail carries more weight than it first appears: a child theme that invents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;kcTokenBannerHeight&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4rem&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;--kc-banner-height&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;for free, with no change to the template and no change to the base.&lt;/p&gt;

&lt;p&gt;The extension point is the properties file, not the code.&lt;/p&gt;

&lt;p&gt;The base currently declares 58 tokens plus 24 dark-mode overrides, grouped roughly by colour scheme, surfaces, brand, status colours, typography, shape, spacing and layout.&lt;/p&gt;

&lt;p&gt;Dark mode reuses the same variable names inside a &lt;code&gt;prefers-color-scheme&lt;/code&gt; block, and only colours are overridden — spacing, radii and typography are shared, because a login form shouldn't change shape when the sun goes down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters across brands
&lt;/h2&gt;

&lt;p&gt;Now put inheritance and tokens together.&lt;/p&gt;

&lt;p&gt;Two products, one authentication architecture.&lt;/p&gt;

&lt;p&gt;The base defines the full token set.&lt;/p&gt;

&lt;p&gt;Brand A overrides one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;kcTokenColorPrimary&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="c"&gt;#2563eb
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Brand B overrides one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;kcTokenColorPrimary&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="c"&gt;#7c3aed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The components don't change.&lt;/p&gt;

&lt;p&gt;The templates don't change.&lt;/p&gt;

&lt;p&gt;The authentication flow certainly doesn't change.&lt;/p&gt;

&lt;p&gt;In practice a child theme is a &lt;code&gt;parent=&lt;/code&gt;, an &lt;code&gt;import=&lt;/code&gt;, a handful of colours, a logo file, and a message bundle entry for the name shown in the admin console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                Base theme
                    │
            Shared components
                    │
              Design tokens
                    │
         ┌──────────┴──────────┐
         │                     │
      Brand A               Brand B
         │                     │
      Blue UI              Purple UI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly the reuse I was looking for, and it is worth being precise about why it works:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The tokens are the only public surface between the base and its children. Everything else is implementation detail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The email theme, where none of this is allowed
&lt;/h2&gt;

&lt;p&gt;Login themes are rendered in a browser.&lt;/p&gt;

&lt;p&gt;Emails are rendered by mail clients, and mail clients are a different century.&lt;/p&gt;

&lt;p&gt;No external stylesheets — clients drop &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; entirely.&lt;/p&gt;

&lt;p&gt;No CSS custom properties — Gmail strips &lt;code&gt;var()&lt;/code&gt;, so the entire mechanism above simply evaporates.&lt;/p&gt;

&lt;p&gt;Outlook's Word engine only reliably understands pixels.&lt;/p&gt;

&lt;p&gt;Flexbox and grid are out; fixed-width tables are in.&lt;/p&gt;

&lt;p&gt;So the same architecture had to arrive by a different route.&lt;/p&gt;

&lt;p&gt;In the email theme, tokens are resolved at render time in FreeMarker and interpolated straight into &lt;code&gt;style&lt;/code&gt; attributes, with a literal fallback if the property is missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;#local colorPrimary = properties.kcTokenColorPrimary!'#4f46e5'&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The design system is identical — same properties, same names, same child overrides.&lt;/p&gt;

&lt;p&gt;Only the delivery differs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the browser resolves variables at paint time&lt;/li&gt;
&lt;li&gt;FreeMarker resolves them at send time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That let all seventeen emails share a single template shell, with the body copy arriving as pre-formatted strings from the message bundles, and all 36 shipped language translations preserved.&lt;/p&gt;

&lt;p&gt;A child theme rebrands every email it sends by changing the same colour it changed for the login page — because both types import the same &lt;code&gt;common&lt;/code&gt; theme.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping overrides intentional
&lt;/h2&gt;

&lt;p&gt;Inheritance is useful.&lt;/p&gt;

&lt;p&gt;Unlimited overriding is not.&lt;/p&gt;

&lt;p&gt;If every derived theme overrides everything, you have arrived back at duplicated themes with extra indirection.&lt;/p&gt;

&lt;p&gt;So the customization boundary has to stay clear, and I find it easier to state as two questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The base theme answers: &lt;strong&gt;how does authentication look and behave by default?&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The derived theme answers: &lt;strong&gt;how does this product want to brand it?&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Layout, forms, buttons, typography and spacing are the base's responsibility — a child may override them, but doing so should feel like a decision, not a default.&lt;/p&gt;

&lt;p&gt;Primary colour, logo, favicon and product-specific assets are the child's responsibility, and always were.&lt;/p&gt;

&lt;p&gt;Everything in between deserves a conversation before it gets copied.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying this to the repository
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ismailza/keycloak-modern-auth" rel="noopener noreferrer"&gt;&lt;code&gt;keycloak-modern-auth&lt;/code&gt;&lt;/a&gt; isn't a sample project built to illustrate a finished idea.&lt;/p&gt;

&lt;p&gt;It's the actual thing, evolving:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v0.1   Keycloak setup: Reproducible Keycloak + PostgreSQL stack
  ↓
v0.2   Custom login theme
  ↓
v0.3   Custom email theme
  ↓
next   Derived themes, configuration as code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I keep evolving one repository instead of creating a fresh one per experiment, and that's deliberate.&lt;/p&gt;

&lt;p&gt;Separate repositories make each example easy to read while hiding the thing I actually care about:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineering decisions are incremental. The first implementation is rarely the final architecture.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The honest progression looks more like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"It works."
      ↓
"But there's duplication."
      ↓
"Extract the common parts."
      ↓
"Make the design configurable."
      ↓
"Now a brand is twenty lines and a logo."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's much closer to how a real project moves.&lt;/p&gt;

&lt;p&gt;The repository stops being sample code and becomes a record of the architectural evolution — including the steps that were replaced.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off
&lt;/h2&gt;

&lt;p&gt;A base theme isn't automatically better.&lt;/p&gt;

&lt;p&gt;It's another abstraction, and abstractions charge rent.&lt;/p&gt;

&lt;p&gt;You now have two artifacts where you had one, and anyone touching the themes has to understand the inheritance relationship before they can safely change anything.&lt;/p&gt;

&lt;p&gt;Debugging gets less direct, too: when a style comes from the derived theme it's obvious, and when it comes from the parent — or from a property merged three levels up — you need to know where to look.&lt;/p&gt;

&lt;p&gt;Whole-file template resolution has a sharp edge as well, since a child that overrides &lt;code&gt;template.ftl&lt;/code&gt; to change one line silently stops inheriting every future improvement to the base.&lt;/p&gt;

&lt;p&gt;This is the classic bargain:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You accept some additional complexity in exchange for less duplication and more consistency.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a single small Keycloak installation, that trade isn't obviously worth it.&lt;/p&gt;

&lt;p&gt;For multiple applications or brands, it stops being close.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;The interesting part of this exercise wasn't writing CSS.&lt;/p&gt;

&lt;p&gt;It was noticing the moment customization turned into architecture.&lt;/p&gt;

&lt;p&gt;At the start, the question was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do I customize Keycloak?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Later it became:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do I make this customization reusable?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those are different questions with different answers.&lt;/p&gt;

&lt;p&gt;The first produces a working theme.&lt;/p&gt;

&lt;p&gt;The second produces a system for producing themes.&lt;/p&gt;

&lt;p&gt;The distinction I try to hold onto is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't introduce an abstraction because it looks elegant. Introduce it when a repeated problem justifies it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here, the repeated theme structure and the need for consistent branding across products supplied the justification.&lt;/p&gt;

&lt;p&gt;Keycloak's inheritance provided the mechanism, and design tokens provided a clean way to make the visual system both reusable and customizable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The base theme is one step, not a destination.&lt;/p&gt;

&lt;p&gt;The directions I want to explore next are mostly about everything around the theme rather than the theme itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more reusable authentication components&lt;/li&gt;
&lt;li&gt;better token organization&lt;/li&gt;
&lt;li&gt;multiple derived brands&lt;/li&gt;
&lt;li&gt;automated validation so a broken theme fails before it reaches an environment&lt;/li&gt;
&lt;li&gt;packaging themes into the image rather than bind-mounting them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Underneath all of that sits a problem I keep running into:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How do you keep Keycloak configuration synchronized across development, staging, qualification and production?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Themes are the visible layer, but realms, clients, roles, mappers and flows are the part that actually drifts.&lt;/p&gt;

&lt;p&gt;That's where theme customization stops being a frontend concern and turns into an infrastructure one — and it's what I want to write about next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;What started as a Keycloak customization is slowly turning into a small authentication platform.&lt;/p&gt;

&lt;p&gt;The lesson isn't that every Keycloak project needs a sophisticated base theme.&lt;/p&gt;

&lt;p&gt;It's that architecture should follow the problems you actually hit.&lt;/p&gt;

&lt;p&gt;Start simple.&lt;/p&gt;

&lt;p&gt;Notice the duplication.&lt;/p&gt;

&lt;p&gt;Learn the extension points the platform already gives you.&lt;/p&gt;

&lt;p&gt;Extract only the parts that are genuinely reusable.&lt;/p&gt;

&lt;p&gt;Then introduce the abstraction.&lt;/p&gt;

&lt;p&gt;The implementation continues to evolve in &lt;a href="https://github.com/ismailza/keycloak-modern-auth" rel="noopener noreferrer"&gt;&lt;code&gt;keycloak-modern-auth&lt;/code&gt;&lt;/a&gt;, alongside this series.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is engineering in practice: not designing the perfect architecture upfront, but improving it as the real problems come into focus.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>architecture</category>
      <category>themes</category>
    </item>
    <item>
      <title>OAuth 2.0, OpenID Connect, and Keycloak: Understanding Modern Authentication</title>
      <dc:creator>Ismail ZAHIR</dc:creator>
      <pubDate>Fri, 07 Aug 2026 14:05:16 +0000</pubDate>
      <link>https://dev.to/ismailzahir/oauth-20-openid-connect-and-keycloak-understanding-modern-authentication-2iej</link>
      <guid>https://dev.to/ismailzahir/oauth-20-openid-connect-and-keycloak-understanding-modern-authentication-2iej</guid>
      <description>&lt;p&gt;OAuth 2.0, OpenID Connect, JWT, access tokens, refresh tokens, Keycloak. These get mentioned in the same breath so often that they blur into a single vague thing called "authentication." They're not one thing. Each solves a distinct problem, and the boundaries between them are sharper than most introductions suggest.&lt;/p&gt;

&lt;p&gt;That blurring has practical consequences. It's why teams send ID tokens to APIs, enforce permissions in the frontend, or reach for a flow that was deprecated years ago — not from carelessness, but because nobody drew the lines clearly in the first place.&lt;/p&gt;

&lt;p&gt;So let's draw them. We'll take each piece in turn, then follow a real login through an Angular app, a Keycloak realm, and a Spring Boot API to see where each one actually does its work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication is not authorization
&lt;/h2&gt;

&lt;p&gt;These two get used interchangeably in conversation, and it causes real bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication&lt;/strong&gt; answers "who are you?" You type a password, a fingerprint gets scanned, a magic link gets clicked. At the end of it, the system knows your identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authorization&lt;/strong&gt; answers "what are you allowed to do?" An admin can manage users. A doctor can open a patient record. A patient can only see their own appointments.&lt;/p&gt;

&lt;p&gt;The ordering matters: you can't decide what someone is allowed to do until you know who they are. Every system does authentication first, authorization second, even when the code makes it look like one step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why OAuth 2.0 exists
&lt;/h2&gt;

&lt;p&gt;Say your application needs to read files from a user's Google Drive.&lt;/p&gt;

&lt;p&gt;The naive approach is to ask for their Google password and log in on their behalf. Hopefully the problem is obvious — you now store a credential that unlocks their email, their photos, their entire account, and you've given yourself unlimited access when you only needed to read one folder.&lt;/p&gt;

&lt;p&gt;OAuth 2.0 exists to avoid exactly this. The user authenticates directly with Google. Google asks whether they want to grant your app access to Drive. If they agree, your app receives a token scoped to that specific permission. You never see the password, and the user can revoke your access at any time without changing it.&lt;/p&gt;

&lt;p&gt;So OAuth 2.0 is an &lt;strong&gt;authorization framework&lt;/strong&gt;: it lets an application act on a user's behalf without ever holding their credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four roles
&lt;/h2&gt;

&lt;p&gt;OAuth defines four participants, and it's worth being able to name them because every OAuth error message assumes you can.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resource Owner&lt;/strong&gt; — the user. They own the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client&lt;/strong&gt; — the application asking for access. Your Angular SPA, a mobile app, a background service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization Server&lt;/strong&gt; — authenticates the user, gets their consent, issues tokens. This is Keycloak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource Server&lt;/strong&gt; — the API holding the protected data. Your Spring Boot or ASP.NET Core backend, which validates the token before responding.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What OpenID Connect adds
&lt;/h2&gt;

&lt;p&gt;Here's the part that trips people up: OAuth 2.0 is not an authentication protocol.&lt;/p&gt;

&lt;p&gt;An access token tells an API "the bearer of this token is allowed to do X." It doesn't reliably tell your &lt;em&gt;application&lt;/em&gt; who the user is. Plenty of teams worked around this by calling some vendor-specific "get me the user" endpoint after the OAuth dance, and every vendor did it differently.&lt;/p&gt;

&lt;p&gt;OpenID Connect (OIDC) standardises that. It's a thin identity layer on top of OAuth 2.0, and its main contribution is the &lt;strong&gt;ID Token&lt;/strong&gt; — a JWT describing the authenticated user, with predictable claim names:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sub&lt;/code&gt; — a stable unique identifier for the user&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;given_name&lt;/code&gt;, &lt;code&gt;family_name&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;email&lt;/code&gt;, &lt;code&gt;email_verified&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;preferred_username&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OIDC also standardises the discovery document, the userinfo endpoint, and logout. Because of it, an OIDC client library works against Keycloak, Auth0, Okta, or Azure AD with nothing more than a URL change.&lt;/p&gt;

&lt;p&gt;Short version: &lt;strong&gt;OAuth 2.0 delegates authorization. OpenID Connect adds authentication.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Keycloak fits
&lt;/h2&gt;

&lt;p&gt;Keycloak is an open-source Identity and Access Management server that implements both specs. Instead of writing your own login page, password hashing, session handling, MFA, and social login, you delegate all of it and your apps just trust Keycloak.&lt;/p&gt;

&lt;p&gt;One note before going further: Keycloak has changed a lot. The old Java adapters (&lt;code&gt;keycloak-spring-boot-starter&lt;/code&gt; and friends) were deprecated years ago and have since been removed, so on Keycloak 26+ you secure a Spring Boot API with Spring Security's own OAuth 2.0 Resource Server support and nothing Keycloak-specific on the backend at all. If a tutorial tells you to add a Keycloak adapter dependency, it predates the Quarkus distribution and you can close the tab.&lt;/p&gt;

&lt;p&gt;A few Keycloak-specific concepts you'll meet immediately:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Realms.&lt;/strong&gt; A realm is an isolated tenant — its own users, roles, clients, and signing keys. Users in realm A don't exist in realm B. A common pattern is one realm for your staff and another for your customers, since they rarely share anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clients, public and confidential.&lt;/strong&gt; Every application that talks to Keycloak is registered as a client. A &lt;em&gt;confidential&lt;/em&gt; client can keep a secret (a backend service). A &lt;em&gt;public&lt;/em&gt; client can't — an Angular SPA ships its entire source to the browser, so anything you call a "secret" there is just a string anyone can read in DevTools. SPAs and mobile apps must be public clients, which is precisely why PKCE exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Realm roles vs client roles.&lt;/strong&gt; Realm roles are global (&lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;doctor&lt;/code&gt;). Client roles are scoped to one application (&lt;code&gt;billing-app:invoice-manager&lt;/code&gt;). If two apps in your realm both need a role called &lt;code&gt;manager&lt;/code&gt; meaning different things, that's your signal to use client roles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The discovery endpoint.&lt;/strong&gt; Every realm exposes its configuration at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://&amp;lt;host&amp;gt;/realms/&amp;lt;realm&amp;gt;/.well-known/openid-configuration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open it in a browser. You get every endpoint URL, the supported flows, and the JWKS URI where your backend fetches public keys. Most client libraries need only this one URL. It's also the fastest way to confirm a realm name is spelled the way you think it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Authorization Code Flow with PKCE
&lt;/h2&gt;

&lt;p&gt;For browser and mobile apps, this is the flow to use. The Implicit Flow you may still see in older tutorials is deprecated — don't.&lt;/p&gt;

&lt;p&gt;Let's walk through it with an Angular app in front of a Spring Boot API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — the app notices you're not logged in.&lt;/strong&gt; No valid token in memory, so it prepares to redirect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — PKCE setup, then redirect.&lt;/strong&gt; Before going anywhere, the app generates a random string called the &lt;code&gt;code_verifier&lt;/code&gt;, hashes it with SHA-256, and base64url-encodes the result into a &lt;code&gt;code_challenge&lt;/code&gt;. The verifier stays in the browser. Only the challenge goes to Keycloak, along with a &lt;code&gt;state&lt;/code&gt; parameter (random, and checked on the way back — that's your CSRF protection) and the redirect URI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — you log in.&lt;/strong&gt; Keycloak renders its login page, checks your credentials, runs MFA if configured, and redirects back to your app with a short-lived &lt;code&gt;authorization code&lt;/code&gt; in the URL. Worth emphasising: this is &lt;em&gt;not&lt;/em&gt; a token. On its own it's useless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 — the exchange.&lt;/strong&gt; The app POSTs the code back to Keycloak's token endpoint, this time including the original &lt;code&gt;code_verifier&lt;/code&gt;. Keycloak hashes it and compares against the challenge from step 2. Match, and you get an access token, an ID token, and a refresh token.&lt;/p&gt;

&lt;p&gt;This is the whole point of PKCE. Authorization codes travel through the browser's address bar, and on mobile through OS-level URL handlers — both interceptable. Without PKCE, an attacker who steals the code can redeem it themselves. With PKCE they'd also need the verifier, which never left the original app. It's a proof that whoever redeems the code is whoever started the flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5 — calling the API.&lt;/strong&gt; The app attaches the access token to requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 6 — the API validates.&lt;/strong&gt; Spring Boot checks the token's signature, issuer, audience, and expiry, then serves the data — or returns &lt;code&gt;401&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the whole exchange in one picture:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzpbv6pkswsemkvic4ph0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzpbv6pkswsemkvic4ph0.png" alt="The Authorization Code Flow with PKCE, end to end" width="800" height="648"&gt;&lt;/a&gt;&lt;br&gt;
The Authorization Code Flow with PKCE, end to end&lt;/p&gt;
&lt;h2&gt;
  
  
  The three tokens
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Access token.&lt;/strong&gt; Presented to APIs. Contains the subject, roles, scopes, and an expiry. Keycloak defaults to a &lt;strong&gt;5-minute&lt;/strong&gt; lifespan, and that's a reasonable default — a leaked token stops being useful quickly.&lt;/p&gt;

&lt;p&gt;A word on &lt;strong&gt;scopes&lt;/strong&gt;, since they're easy to confuse with roles. A scope is what the &lt;em&gt;application&lt;/em&gt; asked permission to do; a role is what the &lt;em&gt;user&lt;/em&gt; is allowed to do. When your Angular app requests &lt;code&gt;openid profile email&lt;/code&gt;, it's saying "I want an ID token, plus the user's profile and email claims" — and the user's consent screen reflects that. In Keycloak these are configured as client scopes, which control both the claims that land in the token and the roles included in it. The distinction matters because a token can carry a powerful role and still be scoped too narrowly for what your API wants to do with it — the API should check both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ID token.&lt;/strong&gt; For the client application only, to answer "who is logged in?" Use it to display a name and avatar. Do not send it to your API as a credential; it isn't scoped for that and a correctly configured API will reject it anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refresh token.&lt;/strong&gt; Longer-lived, used to get a new access token without sending the user back through login. Turn on &lt;strong&gt;refresh token rotation&lt;/strong&gt; — each refresh invalidates the previous token. If a rotated token gets replayed, Keycloak sees a token that's already been used and can kill the whole session. For public clients this isn't optional in my view; it's the only real protection a token you can't keep secret has.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's actually inside an access token
&lt;/h2&gt;

&lt;p&gt;All of this stays abstract until you decode one. A Keycloak access token is a JWT: three base64url segments separated by dots, &lt;code&gt;header.payload.signature&lt;/code&gt;. Paste one into &lt;a href="https://jwt.io/" rel="noopener noreferrer"&gt;jwt.io&lt;/a&gt; and you get something like this.&lt;/p&gt;

&lt;p&gt;The header names the algorithm and, importantly, the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"RS256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JWT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sBv3qHhL2XmR7pKdN9fYcW1uEjTgA4Zo"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1754563200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1754562900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jti"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3f1c7a92-8d4e-4b16-9c05-7ae2f0b8d331"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://auth.example.com/realms/healthcare"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"billing-api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"account"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9b2d41f7-6c8a-4e3b-bf19-2d05c7e4a8f1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"azp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"web-app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"c4a8e102-5f37-49bd-8e6a-1b93d70cf254"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openid profile email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"realm_access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"doctor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"offline_access"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resource_access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"billing-api"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"invoice-manager"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"preferred_username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ismail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ismail@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email_verified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything discussed so far is visible here. &lt;code&gt;iss&lt;/code&gt; is the realm. &lt;code&gt;aud&lt;/code&gt; lists who this token is for. &lt;code&gt;sub&lt;/code&gt; is the stable user ID — note that it's a UUID, not the username, which is why you should key your own database on &lt;code&gt;sub&lt;/code&gt; and never on &lt;code&gt;preferred_username&lt;/code&gt; or &lt;code&gt;email&lt;/code&gt; (both can change). &lt;code&gt;realm_access.roles&lt;/code&gt; and &lt;code&gt;resource_access&lt;/code&gt; are the realm and client roles from earlier, arriving in exactly the shape Keycloak defines. &lt;code&gt;azp&lt;/code&gt; is the client that requested the token.&lt;/p&gt;

&lt;p&gt;One thing worth internalising: this payload is &lt;strong&gt;base64-encoded, not encrypted&lt;/strong&gt;. Anyone holding the token can read every claim in it. That's fine for roles and usernames. It is not fine for anything you'd call confidential, so resist the temptation to stuff internal identifiers or business data into custom claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where do you store tokens?
&lt;/h2&gt;

&lt;p&gt;Every tutorial says "store them securely" and moves on. That phrase hides the single most argued-about decision in frontend auth, so let's be honest about the options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;localStorage&lt;/strong&gt; is the easy path and survives page refreshes. It's also readable by any JavaScript on the page, which means one XSS bug — yours or a compromised npm dependency's — hands over every token you hold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In-memory only&lt;/strong&gt; removes the XSS smash-and-grab, since there's nothing persisted to steal. The cost is that a refresh logs the user out, unless you use silent renewal via a hidden iframe, which third-party cookie restrictions are steadily breaking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend-for-Frontend (BFF)&lt;/strong&gt; keeps tokens on a small server-side layer and gives the browser only an &lt;code&gt;httpOnly&lt;/code&gt; cookie. The browser never touches a token. This is the most secure option and it's where the industry is heading — but it's real infrastructure, and it brings CSRF back into scope.&lt;/p&gt;

&lt;p&gt;There's no universally correct answer. Pick deliberately based on what your app protects, and know what you're trading away. Medical records and a marketing dashboard don't warrant the same call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating tokens on the backend
&lt;/h2&gt;

&lt;p&gt;"Validate the token" also deserves unpacking, because getting it partly right is a genuine vulnerability. Your API must check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Signature&lt;/strong&gt; — verified against Keycloak's public keys from the JWKS endpoint. Libraries fetch and cache these automatically; make sure yours actually does.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;iss&lt;/code&gt; — the issuer matches your realm URL exactly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aud&lt;/code&gt; — the audience includes &lt;em&gt;your&lt;/em&gt; API. This is the one people skip. Without it, a token issued for a different client in the same realm will sail straight through your validation, because the signature is perfectly valid. It just wasn't meant for you.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;exp&lt;/code&gt; — not expired.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any decent OIDC library does all four once configured. The failure mode is almost always a missing or wrong &lt;code&gt;aud&lt;/code&gt;, so check that first when something feels off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key rotation, and why you shouldn't hardcode a public key
&lt;/h2&gt;

&lt;p&gt;Keycloak rotates its realm signing keys — on a schedule, or whenever you rotate them manually after an incident. This is exactly what the &lt;code&gt;kid&lt;/code&gt; in the token header is for: it identifies which key signed this particular token, so old tokens stay verifiable while new ones are signed with the new key.&lt;/p&gt;

&lt;p&gt;Your API should therefore fetch keys from the JWKS endpoint and cache them, refetching when it sees a &lt;code&gt;kid&lt;/code&gt; it doesn't recognise. Spring Security does this out of the box when you point it at the issuer URI. What you must not do is copy a PEM public key out of the admin console and paste it into &lt;code&gt;application.yml&lt;/code&gt; — I've seen it, it works fine for months, and then every request in production returns &lt;code&gt;401&lt;/code&gt; the morning after a key rotation with no obvious cause. Configure the issuer, let the library handle the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things people get wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"OAuth 2.0 is authentication."&lt;/strong&gt; It isn't. It's an authorization framework. Authentication comes from OpenID Connect layered on top.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"JWT and OAuth are the same thing."&lt;/strong&gt; OAuth defines how authorization is delegated. JWT is one possible format for the tokens involved. OAuth works fine with opaque tokens, and plenty of providers use them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"The frontend decides permissions."&lt;/strong&gt; The frontend hides buttons the user can't use. That's UX, not security — anyone can open DevTools and call your API directly. The backend enforces authorization, always, no exceptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Long-lived access tokens improve the user experience."&lt;/strong&gt; They improve it right up until one leaks and stays valid for a week. Short access tokens plus rotating refresh tokens plus automatic renewal gives you the same seamless experience without the exposure window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Authorization Code Flow with PKCE for browser and mobile apps. Never Implicit.&lt;/li&gt;
&lt;li&gt;Access token lifespan in minutes, not hours.&lt;/li&gt;
&lt;li&gt;Refresh token rotation on for public clients.&lt;/li&gt;
&lt;li&gt;Validate signature, &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;aud&lt;/code&gt;, and &lt;code&gt;exp&lt;/code&gt; on every API request.&lt;/li&gt;
&lt;li&gt;Realm roles for cross-application concepts, client roles for app-specific ones.&lt;/li&gt;
&lt;li&gt;HTTPS everywhere in production — a bearer token over plain HTTP is a password over plain HTTP.&lt;/li&gt;
&lt;li&gt;Don't build your own auth server. Really.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Most of the confusion around this stack disappears once each piece has one clear job:&lt;/p&gt;

&lt;p&gt;OAuth 2.0 delegates authorization. OpenID Connect adds authentication and a standard identity format. Keycloak implements both and manages the users. Access tokens authorize API calls, ID tokens identify the user to the app, and refresh tokens keep the session alive without repeated logins.&lt;/p&gt;

&lt;p&gt;Once that clicks, Keycloak's configuration screens stop being a maze and start being a map of the protocol.&lt;/p&gt;

&lt;p&gt;Next in this series: making Keycloak stop looking like Keycloak. We'll build a custom login theme from scratch — FreeMarker templates, CSS custom properties for multi-brand support, and full RTL for Arabic, which is where things get genuinely interesting.&lt;/p&gt;

&lt;p&gt;— Ismail&lt;/p&gt;

</description>
      <category>authentication</category>
      <category>openidconnect</category>
      <category>keycloak</category>
    </item>
    <item>
      <title>Why I Validate Angular Compatibility Using the Published npm Package (Not the Source Code)</title>
      <dc:creator>Ismail ZAHIR</dc:creator>
      <pubDate>Fri, 31 Jul 2026 00:52:23 +0000</pubDate>
      <link>https://dev.to/ismailzahir/why-i-validate-angular-compatibility-using-the-published-npm-package-not-the-source-code-808</link>
      <guid>https://dev.to/ismailzahir/why-i-validate-angular-compatibility-using-the-published-npm-package-not-the-source-code-808</guid>
      <description>&lt;p&gt;I just published a new article on a lesson I learned while maintaining my open-source Angular library.&lt;/p&gt;

&lt;p&gt;I used to validate compatibility against my workspace and &lt;code&gt;dist/&lt;/code&gt;, but eventually realized that wasn't what developers actually install from npm.&lt;/p&gt;

&lt;p&gt;That led me to redesign the CI pipeline to validate the &lt;strong&gt;published package&lt;/strong&gt; across multiple Angular versions instead.&lt;/p&gt;

&lt;p&gt;The article covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why testing &lt;code&gt;dist/&lt;/code&gt; isn't enough&lt;/li&gt;
&lt;li&gt;Angular partial compilation and the linker&lt;/li&gt;
&lt;li&gt;Why type-checking alone can miss compatibility issues&lt;/li&gt;
&lt;li&gt;Validating the packaged artifact with &lt;code&gt;npm pack&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Building a compatibility matrix for Angular 17–22&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you maintain an Angular library (or any npm package), I'd love to hear how you approach compatibility testing.&lt;/p&gt;

&lt;p&gt;📖 &lt;em&gt;Why I Validate Angular Compatibility Using the Published npm Package (Not the Source Code)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://medium.com/@ismailzahir/why-i-validate-angular-compatibility-using-the-published-npm-package-not-the-source-code-32421e090284?sharedUserId=ismailzahir" rel="noopener noreferrer"&gt;Medium link&lt;/a&gt;&lt;/p&gt;

</description>
      <category>angular</category>
      <category>cicd</category>
      <category>opensource</category>
      <category>testing</category>
    </item>
    <item>
      <title>I Stopped Copy-Pasting the Same Angular ApiService. Here’s What I Built Instead</title>
      <dc:creator>Ismail ZAHIR</dc:creator>
      <pubDate>Sun, 26 Jul 2026 12:16:23 +0000</pubDate>
      <link>https://dev.to/ismailzahir/i-stopped-copy-pasting-the-same-angular-apiservice-heres-what-i-built-instead-1o3f</link>
      <guid>https://dev.to/ismailzahir/i-stopped-copy-pasting-the-same-angular-apiservice-heres-what-i-built-instead-1o3f</guid>
      <description>&lt;p&gt;Every Angular project I've worked on had an &lt;code&gt;api.service.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Different repository.&lt;/p&gt;

&lt;p&gt;Different company.&lt;/p&gt;

&lt;p&gt;Same file.&lt;/p&gt;

&lt;p&gt;It always started as a thin wrapper around &lt;code&gt;HttpClient&lt;/code&gt;, then gradually accumulated more responsibility: versioning, authentication, retry logic, loading indicators, error handling.&lt;/p&gt;

&lt;p&gt;By the fourth project, I wasn't writing it anymore.&lt;/p&gt;

&lt;p&gt;I was copying it.&lt;/p&gt;

&lt;p&gt;And not carefully. I'd paste the file in, then spend the next hour re-fixing the same three bugs I'd already fixed in the last project — because I'd never written them down anywhere, just patched them in place and moved on.&lt;/p&gt;

&lt;p&gt;That was the moment I realized it wasn't repeated code anymore.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A pattern repeated four times isn't a pattern anymore. It's a missing dependency.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I extracted it, made the hard-coded parts configurable, and published it as &lt;code&gt;@ismailza/ngx-api-client&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This article isn't really about the library. It's about the four decisions that turned a file I was slightly embarrassed to copy-paste into something I'd defend in a code review.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual gap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;HttpClient&lt;/code&gt; is a good HTTP client. That's genuinely all it claims to be, and people keep being disappointed that it isn't more.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;HttpClient&lt;/code&gt; gives you a request. It does not give you a policy.&lt;/p&gt;

&lt;p&gt;Every application still has to answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Where does the base URL come from, and how does the API version get into it?&lt;/li&gt;
&lt;li&gt;What turns a failed response into something a component can actually render?&lt;/li&gt;
&lt;li&gt;Which requests are safe to retry, and how long do you wait before you do?&lt;/li&gt;
&lt;li&gt;How does a global progress bar find out that anything is in flight?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most applications answer those questions inside an &lt;code&gt;ApiService&lt;/code&gt; that grows organically over time until nobody wants to touch it anymore. I wanted those decisions to live in one place — and to see, at a glance, how a request actually moves through the system:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl55i6zfvess3wfftdpnw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl55i6zfvess3wfftdpnw.png" alt="How a request actually moves through ngx-api-client&lt;br&gt;
" width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A request leaves the component, passes through ApiService, then through an ordered chain — your auth interceptor, retry with jitter, your backend, error normalization, an optional success step — before looping back. Loading state and error handling hang off that chain as pluggable pieces, not built-in opinions.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The first mistake I kept repeating was hard-coding the version
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/v1&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It works...&lt;/p&gt;

&lt;p&gt;...until your backend decides to version using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;headers&lt;/li&gt;
&lt;li&gt;query parameters&lt;/li&gt;
&lt;li&gt;media types&lt;/li&gt;
&lt;li&gt;dates&lt;/li&gt;
&lt;li&gt;or no URL versioning at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of treating versioning as string concatenation, I modeled it as a configurable strategy.&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="nf"&gt;provideApi&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://api.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;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;versioning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same configuration can instead produce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/api/v2/orders&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/orders?v=2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;X-API-Version: 2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Accept: application/vnd.api.v2+json&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;without changing application code.&lt;/p&gt;

&lt;p&gt;Two rules became surprisingly important:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A header or query parameter the caller set explicitly is never overwritten by the versioning strategy.&lt;/strong&gt; If you pass &lt;code&gt;Api-Version&lt;/code&gt; yourself, you meant it. A library that silently clobbers explicit caller input is a library you cannot debug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Versions should accept strings as well as numbers.&lt;/strong&gt; This only became obvious after integrating with a backend that versioned by date:&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="nf"&gt;provideApi&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://api.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;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2024-01-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;versioning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headerName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Api-Version&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I had typed it as &lt;code&gt;number&lt;/code&gt; — which is what my copy-pasted version implicitly assumed — the whole abstraction would have been useless for the exact API that motivated making it configurable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then I realized my biggest problem wasn't requests — it was failures
&lt;/h2&gt;

&lt;p&gt;HTTP failures rarely look the same.&lt;/p&gt;

&lt;p&gt;Sometimes you get a proper RFC 9457 Problem Details response. Well-behaved, easy.&lt;/p&gt;

&lt;p&gt;Sometimes nginx sends back plain text, because it answered before your app ever saw the request.&lt;/p&gt;

&lt;p&gt;Sometimes the network disappears completely and the status is simply &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Components shouldn't need three different code paths for those three situations. Everything gets normalized into one shape:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ApiError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// RFC 9457 problem type URI&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 'Bad Request'&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 0 for a network failure&lt;/span&gt;
  &lt;span class="nl"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// safe to show the user&lt;/span&gt;
  &lt;span class="nl"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// path that produced it&lt;/span&gt;
  &lt;span class="nl"&gt;code&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// machine-readable, e.g. 'VALIDATION_ERROR'&lt;/span&gt;
  &lt;span class="nl"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;traceId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// from the body, else the X-Trace-Id header&lt;/span&gt;
  &lt;span class="nl"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;Once a component receives this, it no longer cares where the failure originated.&lt;/p&gt;

&lt;p&gt;Two things worth stating out loud:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Branch on &lt;code&gt;code&lt;/code&gt;, never on &lt;code&gt;detail&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;detail&lt;/code&gt; is prose meant for a human. It gets reworded, translated, and A/B tested. The day somebody changes "Invalid credentials" to "Incorrect email or password," every &lt;code&gt;if (error.detail === "Invalid credentials")&lt;/code&gt; in your codebase silently stops working.&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="k"&gt;if &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="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INVALID_CREDENTIALS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;code&lt;/code&gt; is the contract. &lt;code&gt;detail&lt;/code&gt; is for humans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;traceId&lt;/code&gt; falls back to the &lt;code&gt;X-Trace-Id&lt;/code&gt; header.&lt;/strong&gt; Nine times out of ten the useful correlation ID is in the response headers and the error body is empty. If your error model discards headers, every production bug report starts with "can you reproduce it?"&lt;/p&gt;




&lt;h2&gt;
  
  
  I almost shipped a toast notification. I'm glad I didn't
&lt;/h2&gt;

&lt;p&gt;That was the easiest feature to add, and the hardest to leave out on purpose.&lt;/p&gt;

&lt;p&gt;The moment a library renders anything, it has an opinion about your design system, your i18n setup, and your accessibility strategy — three things it cannot possibly know. So the library only exposes an error handler that applications replace:&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="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ToastApiErrorHandler&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;ApiErrorHandler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;toast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MyToastService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;override&lt;/span&gt; &lt;span class="nf"&gt;handle&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="nx"&gt;ApiError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toast&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;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&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="nx"&gt;title&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;error&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;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/forbidden&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="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;The HTTP layer reports problems. The application decides how users experience them.&lt;/p&gt;

&lt;p&gt;The practical payoff: peer dependencies are &lt;code&gt;@angular/core&lt;/code&gt;, &lt;code&gt;@angular/common&lt;/code&gt;, and &lt;code&gt;rxjs&lt;/code&gt;. Nothing else. A library sitting in your HTTP layer has no business pulling a UI kit into your bundle.&lt;/p&gt;

&lt;p&gt;One honest caveat I put in the README rather than hiding: the default handler passes the whole &lt;code&gt;ApiError&lt;/code&gt; to Angular's own &lt;code&gt;ErrorHandler&lt;/code&gt;, which logs it. If your API puts sensitive data in &lt;code&gt;detail&lt;/code&gt; or &lt;code&gt;instance&lt;/code&gt;, register your own handler instead of relying on the default. Defaults should be safe, and where they can't be, they should say so.&lt;/p&gt;




&lt;h2&gt;
  
  
  One decision surprised every reviewer — I don't register my own interceptors
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;provideHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;withInterceptors&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;retryInterceptor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;apiErrorInterceptor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;apiSuccessInterceptor&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;This is the one I had to defend hardest in review. At first glance it looks like boilerplate the library should absorb. It isn't, because order matters and only you know what else is in the chain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An auth interceptor has to come first, so a retried request picks up a fresh token instead of replaying the expired one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retryInterceptor&lt;/code&gt; has to come before &lt;code&gt;apiErrorInterceptor&lt;/code&gt;, so your error handler only ever sees failures that survived every retry. Get this backwards and users see three toasts for one eventually-successful request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the library silently injected interceptors at a position it chose, that position would be wrong in some app, and debugging it would mean reading my source. Making the ordering explicit costs one line and buys the ability to reason about the chain.&lt;/p&gt;




&lt;h2&gt;
  
  
  Retry is harder than it looks
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's five characters and it isn't enough. A few rules that all turned out to matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't retry &lt;code&gt;POST&lt;/code&gt; requests by default. It isn't idempotent — replaying it can create two orders. Callers opt in per request when they know their endpoint is safe.&lt;/li&gt;
&lt;li&gt;Respect the server's &lt;code&gt;Retry-After&lt;/code&gt; header. On a 429, the server has told you exactly when to come back; your backoff curve is a guess, the header isn't.&lt;/li&gt;
&lt;li&gt;Retry only transient failures: &lt;code&gt;408, 429, 500, 502, 503, 504&lt;/code&gt;. Retrying a &lt;code&gt;400&lt;/code&gt; is just asking the server to reject you four times.&lt;/li&gt;
&lt;li&gt;Use exponential backoff — and always add jitter.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exponentialDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initialDelay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;multiplier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;retryIndex&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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;jitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;exponentialDelay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exponentialDelay&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;jitter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without jitter, every client that hit your struggling backend at the same moment retries at the same moment, and again two seconds later, and again four seconds after that. You've built a synchronized load test against a server that's already failing. A little randomness breaks the lockstep.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two smaller decisions I'm glad I made
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Per-request configuration travels through &lt;code&gt;HttpContext&lt;/code&gt;, not service state.&lt;/strong&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;LegacyOrder&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/orders&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;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/analytics/event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;skipErrorHandler&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="na"&gt;showLoader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;One request can disable retries, skip global error handling, or bypass loading indicators without touching any concurrent request. A mutable flag on the service that the next interceptor reads would be a race condition waiting for two calls to happen at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loading state is a counter, not a boolean.&lt;/strong&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="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;providedIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;root&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiLoadingService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;activeRequests&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activeRequests&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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;A boolean flickers: three requests start, the fastest one finishes, and the progress bar disappears while two are still running. A counter with a &lt;code&gt;computed()&lt;/code&gt; signal doesn't. It's a five-line class, and it's the single most-copied snippet from every version of this file I ever wrote.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I would tell myself before starting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't extract after the first implementation. Extract after the fourth.&lt;/strong&gt; Three implementations taught me which parts actually varied — the version transport, the error presentation — and which parts never did. Abstracting after one project would have produced configuration options nobody needed, and hard-coded the things that mattered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the README before the code you're unsure about.&lt;/strong&gt; Every section I struggled to explain was a section where the API was wrong. The interceptor-ordering paragraph took three rewrites, and the third is what convinced me not to auto-register them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be honest about compatibility.&lt;/strong&gt; Mine says: developed and tested against Angular 21; the declared floor of &lt;code&gt;&amp;gt;=17&lt;/code&gt; reflects the APIs used — signals and functional interceptors — rather than a range the CI matrix currently covers. Less impressive than claiming full 17–21 support. Also true, which matters more.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Open source isn't about writing perfect software. It's about taking something you've solved the same way four times, pulling out the pattern, and inviting other people to tell you where it's wrong.&lt;/p&gt;

&lt;p&gt;I'm not sure this library is done evolving. But the ideas behind it — versioning as a strategy instead of string concatenation, one error shape no matter what the backend sends back, keeping presentation out of the HTTP layer, and making interceptor order something you choose rather than something the library hides from you — are decisions I'd make again.&lt;/p&gt;

&lt;p&gt;I'm curious how other Angular teams handle this. Do you build your own API layer, or lean directly on &lt;code&gt;HttpClient&lt;/code&gt;? Which of these decisions would you have made differently?&lt;/p&gt;

&lt;p&gt;This is my first technical article, and I'd genuinely love feedback. If you think one of these design decisions is wrong — or you've solved the problem differently — I'd enjoy hearing your perspective.&lt;/p&gt;

&lt;p&gt;The library is on npm as &lt;code&gt;@ismailza/ngx-api-client&lt;/code&gt;, and the source is on GitHub if you'd like to explore the implementation.&lt;/p&gt;

&lt;p&gt;— Ismail&lt;/p&gt;

</description>
      <category>angular</category>
      <category>typescript</category>
      <category>software</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
