<?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: Gaberial Sofie</title>
    <description>The latest articles on DEV Community by Gaberial Sofie (@gaberialsofie).</description>
    <link>https://dev.to/gaberialsofie</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%2F4037853%2F9db7fbce-a297-4e2c-b4ff-82ea86d94637.png</url>
      <title>DEV Community: Gaberial Sofie</title>
      <link>https://dev.to/gaberialsofie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gaberialsofie"/>
    <language>en</language>
    <item>
      <title>I built a production-like local Kubernetes setup in 16 parts — here's the recap and where to grow next</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Sun, 20 Sep 2026 11:12:50 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/i-built-a-production-like-local-kubernetes-setup-in-16-parts-heres-the-recap-and-where-to-grow-4n16</link>
      <guid>https://dev.to/gaberialsofie/i-built-a-production-like-local-kubernetes-setup-in-16-parts-heres-the-recap-and-where-to-grow-4n16</guid>
      <description>&lt;p&gt;This is the capstone of a series on running a genuinely production-like local Kubernetes environment. The whole point was &lt;strong&gt;environment parity&lt;/strong&gt;: catch manifest, RBAC, and service-discovery problems on your laptop instead of from a 3am alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the setup is
&lt;/h2&gt;

&lt;p&gt;k3d (k3s in Docker) cluster &lt;code&gt;dev&lt;/code&gt; with a built-in registry -&amp;gt; a FastAPI image that's the same artifact prod ships -&amp;gt; real manifests (Deployment/Service/Ingress, not docker-compose) -&amp;gt; a fast inner loop via Tilt -&amp;gt; PostgreSQL alongside -&amp;gt; ConfigMap/Secret config -&amp;gt; liveness/readiness probes + requests/limits -&amp;gt; observed by hand via kubectl + k9s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three directions to grow (don't adopt all at once)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitOps (Argo CD / Flux, both CNCF Graduated).&lt;/strong&gt; Git becomes the single source of truth; an in-cluster controller reconciles reality toward it. Argo CD's edge for beginners is a rich web UI (the &lt;code&gt;Application&lt;/code&gt; CRD as an object tree); Flux is the GitOps Toolkit (controllers, image automation, OCI) but has no native UI. Common pattern: Argo for apps, Flux for infra — but pick one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability stack (kube-prometheus-stack, Prometheus Operator, OpenTelemetry).&lt;/strong&gt; One Helm command gives you Prometheus + Grafana + Alertmanager + exporters; scrape your app with a &lt;code&gt;ServiceMonitor&lt;/code&gt; CRD, no hand-edited Prometheus config. Honest caveat: &lt;strong&gt;this stack is heavy and can take down a weak k3d cluster&lt;/strong&gt; — trim components or cap requests. OpenTelemetry unifies traces/metrics/logs (auto-instrumentation for FastAPI).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote-cluster tools (Telepresence, mirrord).&lt;/strong&gt; For the hard cases where local can't hold all the dependencies. mirrord's key nuance is the incoming-traffic mode: &lt;strong&gt;&lt;code&gt;mirror&lt;/code&gt;&lt;/strong&gt; (your process gets a &lt;em&gt;copy&lt;/em&gt; of traffic — safe on shared clusters) vs &lt;strong&gt;&lt;code&gt;steal&lt;/code&gt;&lt;/strong&gt; (your process &lt;em&gt;intercepts&lt;/em&gt; the Pod's traffic — diverts others' requests, use carefully). Telepresence intercepts via a VPN/tun model (needs root + an in-cluster component).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The measured posture: here's the ladder, don't climb it all at once. Reach for remote-dev tools only when a local cluster genuinely can't handle the scenario.&lt;/p&gt;

&lt;p&gt;Full article: &lt;a href="https://dorokhovich.com/blog/local-k8s-conclusion-and-next-steps?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-conclusion-and-next-steps" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-conclusion-and-next-steps?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-conclusion-and-next-steps&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>k3d</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Local Kubernetes Dev — Part 16: Conclusion and next steps</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Fri, 18 Sep 2026 07:53:00 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/local-kubernetes-dev-part-16-conclusion-and-next-steps-51bh</link>
      <guid>https://dev.to/gaberialsofie/local-kubernetes-dev-part-16-conclusion-and-next-steps-51bh</guid>
      <description>&lt;p&gt;The local Kubernetes series is complete — 16 parts.&lt;/p&gt;

&lt;p&gt;If you're new and just arrived — start with chapter one on the inner dev loop. This final one pulls it all together: what we built and where to grow next.&lt;/p&gt;

&lt;p&gt;What we ended up with — a real (not emulated) cluster on your laptop: k3d + a FastAPI image + real manifests (Deployment/Service/Ingress) + a fast inner loop on Tilt + PostgreSQL alongside + config/secrets + probes and limits + k9s. The main payoff is environment parity: manifest, RBAC, and service discovery problems show up locally, not via alerts at 3 a.m.&lt;/p&gt;

&lt;p&gt;And three honest directions to grow (don't grab all at once):&lt;br&gt;
• GitOps (Argo CD / Flux) — when the team and the number of environments grow;&lt;br&gt;
• an observability stack (kube-prometheus-stack, Prometheus Operator, OpenTelemetry) — with a warning: a heavy stack can take down a weak k3d;&lt;br&gt;
• remote development (Telepresence, mirrord) — and the difference between mirror mode (a copy of traffic, safe) vs steal (interception).&lt;/p&gt;

&lt;p&gt;Read the finale and jump back to any chapter: &lt;a href="https://dorokhovich.com/blog/local-k8s-conclusion-and-next-steps?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-conclusion-and-next-steps" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-conclusion-and-next-steps?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-conclusion-and-next-steps&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>k3d</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Ingress does nothing until you have an Ingress controller (and 3 more local-k8s networking traps)</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Sun, 30 Aug 2026 23:43:09 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/your-ingress-does-nothing-until-you-have-an-ingress-controller-and-3-more-local-k8s-networking-he</link>
      <guid>https://dev.to/gaberialsofie/your-ingress-does-nothing-until-you-have-an-ingress-controller-and-3-more-local-k8s-networking-he</guid>
      <description>&lt;p&gt;The single most common local-Kubernetes frustration is "I applied an Ingress and nothing responds." The docs are blunt about why: &lt;em&gt;"You must have an Ingress controller to satisfy an Ingress. Only creating an Ingress resource has no effect."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An Ingress manifest is just rules.&lt;/strong&gt; It needs a running controller (Traefik, NGINX...) that reads them and actually accepts traffic. Which controller serves it is set by &lt;code&gt;ingressClassName&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;k3d ships Traefik out of the box&lt;/strong&gt; (k3d is built on k3s), plus a built-in ServiceLB (Klipper) so &lt;code&gt;LoadBalancer&lt;/code&gt; services don't hang in &lt;code&gt;pending&lt;/code&gt;. But Traefik listens &lt;em&gt;inside&lt;/em&gt; the cluster — to reach it from the host you must forward a port &lt;strong&gt;at cluster-creation time&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  k3d cluster create dev &lt;span class="nt"&gt;--api-port&lt;/span&gt; 6550 &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"8081:80@loadbalancer"&lt;/span&gt; &lt;span class="nt"&gt;--agents&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You cannot add this port map to a running cluster. Forgot it? Recreate. This is the #2 pain after "forgot the controller."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DNS inside the cluster is by name, not IP&lt;/strong&gt; (CoreDNS). FQDN pattern: &lt;code&gt;&amp;lt;service&amp;gt;.&amp;lt;namespace&amp;gt;.svc.cluster.local&lt;/code&gt;. Short names resolve only within the same namespace via search domains — cross-namespace you need &lt;code&gt;postgres.myapp&lt;/code&gt; or the full FQDN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;*.localhost&lt;/code&gt; is DNS behaving to spec, not k3d magic.&lt;/strong&gt; RFC 6761 reserves &lt;code&gt;.localhost&lt;/code&gt; for loopback. Chrome and Firefox resolve any &lt;code&gt;*.localhost&lt;/code&gt; to 127.0.0.1 with zero config — but Safari and non-browser tools (&lt;code&gt;curl&lt;/code&gt;, HTTP clients) treat it as an ordinary domain and hit the OS resolver, giving &lt;code&gt;could not resolve host&lt;/code&gt;. Fix: add it to &lt;code&gt;/etc/hosts&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full traffic chain to memorize: &lt;code&gt;curl -&amp;gt; host port 8081 -&amp;gt; Traefik (port 80) -&amp;gt; Service myapp -&amp;gt; Pod&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Full article: &lt;a href="https://dorokhovich.com/blog/local-k8s-networking-and-ingress?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-networking-and-ingress" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-networking-and-ingress?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-networking-and-ingress&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Local Kubernetes Dev — Part 11: Networking — reaching your service and Ingress</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Sat, 29 Aug 2026 16:47:12 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/local-kubernetes-dev-part-11-networking-reaching-your-service-and-ingress-30pa</link>
      <guid>https://dev.to/gaberialsofie/local-kubernetes-dev-part-11-networking-reaching-your-service-and-ingress-30pa</guid>
      <description>&lt;p&gt;"I applied an Ingress — and it doesn't respond. Why?"&lt;/p&gt;

&lt;p&gt;A local Kubernetes classic: you write an Ingress manifest, kubectl apply, open the browser — and silence. The thing is, an Ingress by itself is just rules. Until there's an Ingress controller in the cluster (Traefik, NGINX) that reads those rules and accepts traffic, nothing works. Straight from the docs: "Only creating an Ingress resource has no effect."&lt;/p&gt;

&lt;p&gt;And the second most popular k3d pain: the loadbalancer port mapping is set ONLY at cluster creation. Forgot -p "8081:80@loadbalancer"? You'll have to recreate the cluster.&lt;/p&gt;

&lt;p&gt;In the new article (part 11) I break networking down layer by layer:&lt;br&gt;
• how Pods find each other by name via CoreDNS (and why the short name only works inside its own namespace);&lt;br&gt;
• three ways to reach in from outside: port-forward, NodePort, Ingress — and when to use which;&lt;br&gt;
• Traefik out of the box in k3d and the whole chain: curl → port 8081 → Traefik → Service → Pod;&lt;br&gt;
• why &lt;a href="http://myapp.localhost" rel="noopener noreferrer"&gt;http://myapp.localhost&lt;/a&gt; opens in Chrome but curl says "could not resolve host" (spoiler: RFC 6761, and it's not a bug).&lt;/p&gt;

&lt;p&gt;Read it and stop fighting your cluster's networking: &lt;a href="https://dorokhovich.com/blog/local-k8s-networking-and-ingress?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-networking-and-ingress" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-networking-and-ingress?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-networking-and-ingress&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Running Postgres, Redis and RabbitMQ inside your local k3d cluster (with the 2025 Bitnami trap)</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Mon, 24 Aug 2026 15:50:55 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/running-postgres-redis-and-rabbitmq-inside-your-local-k3d-cluster-with-the-2025-bitnami-trap-5gk7</link>
      <guid>https://dev.to/gaberialsofie/running-postgres-redis-and-rabbitmq-inside-your-local-k3d-cluster-with-the-2025-bitnami-trap-5gk7</guid>
      <description>&lt;p&gt;Chapter 9 of a local-Kubernetes series: the stateful-dependencies chapter the earlier ones deliberately deferred. Bring up your backing services &lt;em&gt;in the cluster&lt;/em&gt; so local structurally matches prod — and avoid a 2025 supply-chain landmine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Why in-cluster, not compose-on-the-side:&lt;/strong&gt; one world, not two. In-cluster Postgres/Redis/RabbitMQ gets you unified DNS/service discovery (app finds the DB at &lt;code&gt;postgres&lt;/code&gt;, not &lt;code&gt;localhost:5432&lt;/code&gt;), shared Secrets/ConfigMaps, and you catch RBAC/limits/probe problems before rollout. (Fair: compose is a legit choice if the service &lt;em&gt;isn't&lt;/em&gt; going to k8s.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Bitnami warning without which this chapter would be harmful:&lt;/strong&gt; since 2025-09-29 most public Bitnami OCI charts/images moved behind a Broadcom subscription; the rest are &lt;code&gt;bitnamilegacy&lt;/code&gt; (unsupported, no security patches). Don't blindly recommend &lt;code&gt;bitnami/*&lt;/code&gt; in 2026. Use &lt;strong&gt;Chainguard's drop-in charts&lt;/strong&gt;, official operators, or a deliberately-pinned legacy tag (dev only).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The most predictable dev path is a raw 3-object manifest:&lt;/strong&gt; Deployment (official &lt;code&gt;postgres:17&lt;/code&gt;) + Service (stable DNS &lt;code&gt;postgres&lt;/code&gt;) + PVC — no chart repos, no subscription dependency. Includes a &lt;code&gt;pg_isready&lt;/code&gt; readiness probe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The big storage surprise:&lt;/strong&gt; in k3d, data is &lt;strong&gt;ephemeral by default&lt;/strong&gt; — &lt;code&gt;local-path-provisioner&lt;/code&gt; writes inside the node container, so &lt;code&gt;k3d cluster delete&lt;/code&gt; wipes it. To persist, map a host dir at create: &lt;code&gt;--volume $HOME/k3d-storage:/var/lib/rancher/k3s/storage@all&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wire into Tilt:&lt;/strong&gt; feed the manifest to &lt;code&gt;k8s_yaml&lt;/code&gt;, order startup with &lt;code&gt;resource_deps&lt;/code&gt; (&lt;code&gt;myapp&lt;/code&gt; waits for &lt;code&gt;postgres&lt;/code&gt;'s readiness), &lt;code&gt;port_forwards=['5432:5432']&lt;/code&gt; to reach it from your IDE.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrations:&lt;/strong&gt; a &lt;strong&gt;separate Job&lt;/strong&gt; (+ initContainer wait), ordered &lt;code&gt;postgres → migrate → seed → myapp&lt;/code&gt;. Migrations in the app's own initContainer is an anti-pattern (races across replicas, probe kills). Note Tilt's built-in &lt;code&gt;helm()&lt;/code&gt; &lt;strong&gt;skips hooks&lt;/strong&gt; — use &lt;code&gt;helm_resource&lt;/code&gt; for anything with init hooks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mock vs run:&lt;/strong&gt; OSS deps (Postgres/Redis/RabbitMQ) → run as-is; proprietary managed (S3/SQS/DynamoDB) → emulate with LocalStack, but it's not 1:1 (IAM, consistency, edge cases).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full article: &lt;a href="https://dorokhovich.com/blog/local-k8s-dependencies-databases-queues?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-dependencies-databases-queues" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-dependencies-databases-queues?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-dependencies-databases-queues&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Local Kubernetes Dev — Part 9: Dependencies — databases, queues, caches</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Fri, 21 Aug 2026 08:42:31 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/local-kubernetes-dev-part-9-dependencies-databases-queues-caches-f13</link>
      <guid>https://dev.to/gaberialsofie/local-kubernetes-dev-part-9-dependencies-databases-queues-caches-f13</guid>
      <description>&lt;p&gt;OK, your service runs in the cluster locally — but what about the database, the queue, the cache? This is the chapter the previous ones deliberately put off.&lt;/p&gt;

&lt;p&gt;Part nine of the series is about stateful dependencies. We stand up PostgreSQL, Redis, and RabbitMQ INSIDE the local cluster (Helm or a "raw" manifest), wire them into Tilt, and sort out PersistentVolumes, migrations, and seeding on startup. The point is parity: if the database lives in the same cluster with the same charts that ship to staging, you get unified DNS and service discovery for free (the app finds the DB by the name &lt;code&gt;postgres&lt;/code&gt;, not &lt;code&gt;localhost:5432&lt;/code&gt;), shared Secrets/ConfigMaps, and you catch manifest problems before prod — not on a Friday evening.&lt;/p&gt;

&lt;p&gt;An honest warning, without which this chapter would do more harm than good: as of 2025-09-29 the public Bitnami images moved behind a paid Broadcom subscription, so blindly recommending &lt;code&gt;bitnami/*&lt;/code&gt; in 2026 is no longer an option. What to do instead — Chainguard drop-in charts, official operators, or a deliberate pin to a legacy tag. For dev, the most predictable path is a "raw" manifest of three objects (Deployment + Service + PVC) with the official &lt;code&gt;postgres:17&lt;/code&gt; image.&lt;/p&gt;

&lt;p&gt;Plus: the biggest surprise — in k3d data is ephemeral by default (fixed with &lt;code&gt;--volume $HOME/...:/var/lib/rancher/k3s/storage@all&lt;/code&gt;), migrations as a separate Job (not in the app's initContainer — that's an antipattern), and when to mock a managed service (RDS/SQS) with LocalStack and when not to. &lt;a href="https://dorokhovich.com/blog/local-k8s-dependencies-databases-queues?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-dependencies-databases-queues" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-dependencies-databases-queues?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-dependencies-databases-queues&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>One Realm for Every App and Tenant Was a Time Bomb: A Keycloak Realms Clients Roles Threat Model</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Wed, 12 Aug 2026 12:32:06 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/one-realm-for-every-app-and-tenant-was-a-time-bomb-a-keycloak-realms-clients-roles-threat-model-4lj4</link>
      <guid>https://dev.to/gaberialsofie/one-realm-for-every-app-and-tenant-was-a-time-bomb-a-keycloak-realms-clients-roles-threat-model-4lj4</guid>
      <description>&lt;h2&gt;
  
  
  The exposure
&lt;/h2&gt;

&lt;p&gt;The first Keycloak rollout worked on the first try, which is the dangerous part. Under the hood it was one realm for every app and every tenant, wildcard redirect URIs, a client secret embedded in a single-page app, and roles assigned directly to individual users. None of it threw an error, and that is precisely why it festered for six months until we had three apps, two customer tenants, and a growing suspicion the whole thing was held together with tape.&lt;/p&gt;

&lt;p&gt;Understanding &lt;strong&gt;keycloak realms clients roles&lt;/strong&gt; correctly is a security exercise, not a modeling preference, because each of those four sins maps to a concrete class of vulnerability. A working login flow and a safe one look identical from the outside, and the console will happily let you build the former long before you have the latter. Before changing anything it was worth cataloguing exactly what each shortcut exposed us to. For the concept model and the remediation I worked from &lt;a href="https://dorokhovich.com/blog/understanding-keycloak-realms-clients-roles?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=understanding-keycloak-realms-clients-roles" rel="noopener noreferrer"&gt;a thorough third-party guide to Keycloak's core concepts →&lt;/a&gt; alongside the official administration guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat model
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No tenant isolation — shared blast radius.&lt;/strong&gt; One realm carrying all apps and both tenants means a misconfiguration, a leaked admin credential, or a bad role change in one tenant's context has blast radius across every tenant. There is no boundary to contain a mistake, which is the opposite of what a realm is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wildcard redirect URIs — open redirect and token exfiltration.&lt;/strong&gt; &lt;code&gt;http://localhost:3000/*&lt;/code&gt; was convenient in dev and quietly rode into prod. A wildcard redirect lets an attacker steer the authorization-code callback to a URL they control, turning a valid login into a token-leak primitive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client secret in a SPA — a secret that is not secret.&lt;/strong&gt; The single-page app was registered as if it could hold a secret; the secret shipped in the browser bundle, readable by anyone. A confidential client whose secret is public is a confidential client in name only, and it undermines the entire client-authentication assumption.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-user role assignment — unauditable and unscalable.&lt;/strong&gt; Assigning roles to individuals by hand means "who can do X?" is answerable only by enumerating every user, and every onboarding is a manual chance to over-grant. Least privilege is not enforceable if entitlements are scattered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The organizing principle for the fix is the three-word model: realms isolate identities, clients integrate apps, roles authorize actions. Every defect above was a violation of one of those three boundaries, so the remediation is to restore each boundary deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controls we added
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Control 1 — realms as isolation boundaries
&lt;/h3&gt;

&lt;p&gt;A realm is a self-contained boundary with its own users, groups, clients, and roles — Keycloak's definition is that "a realm manages a set of users, credentials, roles, and groups" (&lt;a href="https://www.keycloak.org/docs/latest/server_admin/index.html" rel="noopener noreferrer"&gt;Server Administration Guide&lt;/a&gt;). We split by tenant, and at minimum by environment (dev/staging/prod), so a misconfiguration in one is contained to one. Users no longer span realms automatically, and that is the point: if identity genuinely needs to be shared, identity brokering or federation is the explicit, reviewable tool for it, rather than an implicit shared bucket.&lt;/p&gt;

&lt;p&gt;There is a ceiling to this control worth stating honestly, because over-applying it creates its own risk. Realm-per-tenant is right for a handful of tenants but stops scaling around a couple dozen — past that you are operating an IAM platform instead of shipping a product, and a sprawl of realms becomes its own misconfiguration surface. Keycloak's &lt;strong&gt;Organizations&lt;/strong&gt; feature (preview in 25, GA in 26 — &lt;a href="https://www.keycloak.org/2024/06/announcement-keycloak-organizations" rel="noopener noreferrer"&gt;announcement&lt;/a&gt;) provides first-class multi-tenancy inside a single realm for that case. The rule: separate realms to isolate genuinely different security domains (different admins, or dev/staging/prod); Organizations to partition many customers who share one app's trust boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 2 — clients with an honest access type and PKCE
&lt;/h3&gt;

&lt;p&gt;A client's &lt;strong&gt;access type&lt;/strong&gt; is a security control, not a convenience setting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;public&lt;/strong&gt; — SPAs and native apps; no secret, because anything shipped to a browser or device is readable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;confidential&lt;/strong&gt; — server-side apps that can actually keep a secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bearer-only&lt;/strong&gt; — APIs that verify tokens and never start a login flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SPA became a &lt;strong&gt;public&lt;/strong&gt; client, and the leaked secret was retired rather than rotated-in-place, because the fix is to stop pretending a browser bundle is private, not to ship a fresh secret into the same exposure. What replaces the secret is &lt;strong&gt;PKCE&lt;/strong&gt; (&lt;code&gt;S256&lt;/code&gt;): designed specifically for OAuth public clients that cannot keep a secret (&lt;a href="https://www.rfc-editor.org/rfc/rfc7636" rel="noopener noreferrer"&gt;RFC 7636&lt;/a&gt;), it stops an intercepted authorization code from being redeemed by an attacker (&lt;a href="https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow-with-pkce" rel="noopener noreferrer"&gt;Auth0's PKCE explainer&lt;/a&gt;). Turning it on is non-negotiable for browser and native clients. Alongside it, the wildcards died: valid redirect URIs and web origins are now exact entries, no &lt;code&gt;*&lt;/code&gt;, to close the open-redirect vector.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 3 — roles and groups for least privilege by construction
&lt;/h3&gt;

&lt;p&gt;Roles model permissions, and we now use two flavors deliberately: &lt;strong&gt;realm roles&lt;/strong&gt; for org-wide levels (&lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;viewer&lt;/code&gt;) and &lt;strong&gt;client roles&lt;/strong&gt; for app-specific permissions (&lt;code&gt;billing:read&lt;/code&gt;). &lt;strong&gt;Composite roles&lt;/strong&gt; let one &lt;code&gt;Admin&lt;/code&gt; assignment imply a bundle — though a composite is also where over-grant hides, so each composite is reviewed for exactly what it expands to. The biggest change was mapping roles to &lt;strong&gt;groups&lt;/strong&gt;, not individuals: assigning to users does not scale and is not auditable; group-based mapping is both. A new hire joins a group and inherits precisely the intended roles, which makes least privilege the default path rather than a discipline someone has to remember.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 4 — authorize from the token, not from identity
&lt;/h3&gt;

&lt;p&gt;The refactor only pays off if the application uses the structure. The early mistake was scattering permission checks keyed on usernames — a hard-coded allow-list is unauditable and drifts silently. The clean version reads roles straight from the token: in OIDC, Keycloak places realm roles under &lt;code&gt;realm_access.roles&lt;/code&gt; and client roles under &lt;code&gt;resource_access[client].roles&lt;/code&gt;. An authorization check becomes "does this token carry the &lt;code&gt;editor&lt;/code&gt; role?" rather than "is this user in my admin list?" Because &lt;code&gt;Admin&lt;/code&gt; is modeled as a composite, one role in the token expands to everything it should, consistently. Permissions become a configuration concern in Keycloak, not a deployment concern in the codebase — a new hire is added to a group, the roles land in their token, and the app authorizes them with no code change or redeploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the controls, not just shipping them
&lt;/h2&gt;

&lt;p&gt;The rebuild started from a minimal, reviewable baseline rather than clicking around the console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;KEYCLOAK_ADMIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;admin &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;KEYCLOAK_ADMIN_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;admin &lt;span class="se"&gt;\&lt;/span&gt;
  quay.io/keycloak/keycloak:25.0.2 start-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a five-step skeleton: a dedicated realm per security domain, each app as its own client with exact redirect URIs and the correct access type, realm roles like &lt;code&gt;Admin&lt;/code&gt; and &lt;code&gt;User&lt;/code&gt;, and assignment via groups. The verification that mattered most was that the controls are now demonstrable: "who can write billing?" used to mean grepping code and cross-referencing a user table; now it is "which groups carry a role that composes &lt;code&gt;billing:write&lt;/code&gt;?", answerable in the admin console in seconds. An entitlement you can query is an entitlement you can audit, and negative-testing a redirect URI with a deliberately invalid value confirms the allow-list actually rejects what it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  Residual risk / what we're still watching
&lt;/h2&gt;

&lt;p&gt;Restoring the three boundaries removed the acute exposures, but each control carries its own residual risk.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Realm sprawl vs. Organizations.&lt;/strong&gt; Separate realms isolate cleanly at small scale but become an operational and misconfiguration burden past a couple dozen tenants. We are watching the tenant count against the point where Organizations-in-one-realm is the safer trade, since the wrong choice in either direction reintroduces risk — sprawl on one side, a weaker isolation boundary on the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PKCE and redirect-URI drift.&lt;/strong&gt; The public-client posture depends on PKCE staying enforced and redirect URIs staying exact. A future convenience edit that re-adds a wildcard, or a client that quietly disables PKCE, silently reopens the token-leak vector. Both live in reviewed configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composite-role over-grant.&lt;/strong&gt; Composites are ergonomic but concentrate authority; a role added to a widely-assigned composite grants it to everyone downstream at once. We review composite membership as an entitlement change, not a convenience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group hygiene.&lt;/strong&gt; Group-based assignment scales, but it moves the risk to group membership — a stale or over-broad group is now the thing to audit. We periodically reconcile group membership against intended access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The leaked secret is retired but assume-compromised.&lt;/strong&gt; Because the old SPA secret was public for months, we treat it as burned permanently and monitor for any client still configured to accept it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson that stuck is that the console lets you build something that works long before it is something that is safe, and the three-word model — isolate, integrate, authorize — is both the setup mantra and the operating model that keeps the system explainable, and auditable, a year later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.keycloak.org/docs/latest/server_admin/index.html" rel="noopener noreferrer"&gt;Keycloak — Server Administration Guide&lt;/a&gt; — realms, clients, roles, groups, and composite roles.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.keycloak.org/2024/06/announcement-keycloak-organizations" rel="noopener noreferrer"&gt;Keycloak — Organizations announcement&lt;/a&gt; — multi-tenancy inside a single realm (preview in 25, GA in 26).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc7636" rel="noopener noreferrer"&gt;RFC 7636 — Proof Key for Code Exchange&lt;/a&gt; — why public clients use PKCE instead of a secret.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow-with-pkce" rel="noopener noreferrer"&gt;Auth0 — Authorization Code Flow with PKCE&lt;/a&gt; — a clear walkthrough of the SPA/native login flow.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/understanding-keycloak-realms-clients-roles?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=understanding-keycloak-realms-clients-roles" rel="noopener noreferrer"&gt;A Keycloak realms/clients/roles walkthrough&lt;/a&gt; — a useful third-party account of the pitfalls, the cross-realm FAQ, and a runnable five-step example.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>A Typosquatted Gem Almost Shipped, Caught by Luck: A Ruby Supply Chain Security Playbook</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Tue, 11 Aug 2026 19:26:01 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/a-typosquatted-gem-almost-shipped-caught-by-luck-a-ruby-supply-chain-security-playbook-4f6b</link>
      <guid>https://dev.to/gaberialsofie/a-typosquatted-gem-almost-shipped-caught-by-luck-a-ruby-supply-chain-security-playbook-4f6b</guid>
      <description>&lt;h2&gt;
  
  
  The exposure
&lt;/h2&gt;

&lt;p&gt;A teammate opened a PR adding a small utility gem. The name looked right at a glance — one transposed character from a popular package. It passed our tests. On a normal Friday it would have merged. Someone caught the typo in review by luck, not process, and the gem turned out to be a typosquat whose &lt;code&gt;extconf.rb&lt;/code&gt; ran code at install time that had no business running.&lt;/p&gt;

&lt;p&gt;"By luck, not process" is the phrase that reframes this from an anecdote into a security finding. &lt;strong&gt;Ruby supply chain security&lt;/strong&gt; is the discipline of not depending on luck — or on someone else's controls — for the integrity of code you ship. We were trusting &lt;code&gt;gem install&lt;/code&gt; blindly and leaning entirely on RubyGems.org to catch everything upstream. It usually does, but "usually" is a probability, not a guarantee, and a plan that works right up until it doesn't is not a control. RubyGems.org's own &lt;a href="https://blog.rubygems.org/2025/08/25/rubygems-security-response.html" rel="noopener noreferrer"&gt;account of how it protects the ecosystem&lt;/a&gt; puts a number on it: its automated tooling catches roughly 70-80% of malicious packages before anyone reports them. That upstream layer is real and valuable, but the residual 20-30% is precisely the population your build has to assume it will meet. For the fuller hardening notes I worked from &lt;a href="https://dorokhovich.com/blog/rubygems-supply-chain-security?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=rubygems-supply-chain-security" rel="noopener noreferrer"&gt;a thorough third-party writeup of a Ruby dependency-security playbook →&lt;/a&gt; alongside the registry's post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat model
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Typosquatting and look-alike names.&lt;/strong&gt; A one-character-off gem name is a social-engineering primitive aimed at review and autocomplete. It is caught, if at all, by attention — an unreliable control at 2pm on a Friday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arbitrary code execution at install time.&lt;/strong&gt; &lt;code&gt;extconf.rb&lt;/code&gt;, native extension builds, and install hooks run on developer laptops and CI runners with whatever privileges those hosts hold. A malicious gem does not need to reach production to do damage; installing it is the exploit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lockfile injection and source substitution.&lt;/strong&gt; A version &lt;em&gt;number&lt;/em&gt; can be re-pointed at different bytes upstream, and a machine-generated &lt;code&gt;Gemfile.lock&lt;/code&gt; that nobody reads during review can quietly point a dependency at a different set of bytes than the manifest implies. Mixed gem sources open a substitution path where a gem is pulled from an unexpected origin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The post-disclosure window.&lt;/strong&gt; A gem that is clean today gets a CVE tomorrow while sitting in your lockfile. Detection that only runs when code changes never revisits already-shipped dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-reliance on upstream controls.&lt;/strong&gt; Delegating your production security entirely to the registry's 70-80% means the ecosystem's residual miss rate is your residual risk, unmediated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The organizing principle is defense in depth: the registry is the first line, and the build must be a deliberate second line behind it, because malicious packages are not rare events — they are background noise of the modern software supply chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controls we added
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Control 1 — shrink the attack surface
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin versions and platforms in &lt;code&gt;Gemfile.lock&lt;/code&gt; and commit it.&lt;/strong&gt; No floating ranges into production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No git dependencies without a commit SHA.&lt;/strong&gt; A branch can be rewritten; a SHA cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narrow ranges (&lt;code&gt;~&amp;gt;&lt;/code&gt;) for critical gems&lt;/strong&gt;, and &lt;strong&gt;prune ruthlessly&lt;/strong&gt; — every unused and transitive gem is surface area, reviewed on a quarterly dependency-hygiene day.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Control 2 — checksum verification, the highest-leverage single addition
&lt;/h3&gt;

&lt;p&gt;Pinning a version number is necessary but not sufficient, because the number can be re-pointed at different bytes. Bundler 2.6 makes checksum verification first-class (&lt;a href="https://bundler.io/man/bundle-lock.1.html" rel="noopener noreferrer"&gt;&lt;code&gt;--add-checksums&lt;/code&gt; is a documented &lt;code&gt;bundle lock&lt;/code&gt; flag&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bundle lock &lt;span class="nt"&gt;--add-checksums&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This writes a &lt;code&gt;CHECKSUMS&lt;/code&gt; section into &lt;code&gt;Gemfile.lock&lt;/code&gt;, and from then on Bundler refuses to install a gem whose contents do not match the exact bytes you locked. It closes the nastiest gap in a pin-the-version-only strategy: a checksum cannot be quietly swapped the way a version can be re-pointed. If you adopt one new control this quarter, make it this one. Alongside it, a few Bundler settings harden source handling — &lt;code&gt;disable_multisource true&lt;/code&gt; (the underrated one, blocking the source-substitution class), &lt;code&gt;cache_all true&lt;/code&gt;, and &lt;code&gt;clean 'true'&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 3 — make the human review a process, not a hero act
&lt;/h3&gt;

&lt;p&gt;The near-miss was caught by a person, so we turned the person into a checklist. Adding or bumping a gem now requires a short "why this gem?" note and a CHANGELOG link in the PR, documented in the PR template where the work happens rather than a wiki nobody reads. Dependabot and GitHub alerts are enabled so advisories are not manually watched. The cultural shift — that a dependency is code we are adopting, not a freebie we are grabbing — mattered more than any single tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 4 — gate it in CI
&lt;/h3&gt;

&lt;p&gt;A checklist people can forget is a suggestion; a CI gate is a rule. Two independent scanners catch different things, and layering them is cheap: &lt;a href="https://github.com/rubysec/bundler-audit" rel="noopener noreferrer"&gt;bundler-audit&lt;/a&gt; (checking &lt;code&gt;Gemfile.lock&lt;/code&gt; against the &lt;a href="https://github.com/rubysec/ruby-advisory-db" rel="noopener noreferrer"&gt;ruby-advisory-db&lt;/a&gt;) and Google's &lt;a href="https://github.com/google/osv-scanner" rel="noopener noreferrer"&gt;OSV-Scanner&lt;/a&gt; (fed by &lt;a href="https://osv.dev/" rel="noopener noreferrer"&gt;osv.dev&lt;/a&gt;). It runs on every dependency-touching PR and weekly on a schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Gemfile'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Gemfile.lock'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;   &lt;span class="c1"&gt;# weekly on Mondays&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;bundler-audit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ruby/setup-ruby@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;ruby-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.3'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;bundler-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gem install bundler-audit&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bundle audit check --update&lt;/span&gt;
  &lt;span class="na"&gt;osv-scanner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;google/osv-scanner-action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;scan-args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-L&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-r&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The weekly &lt;code&gt;cron&lt;/code&gt; addresses the post-disclosure window directly: a vulnerability disclosed on Wednesday fails the build even if nobody touches the code. For teams that want typosquat detection and SBOM generation on top, a Ruby-specific scanner like &lt;a href="https://github.com/wilburhimself/gem_guard" rel="noopener noreferrer"&gt;GemGuard&lt;/a&gt; — pulling from OSV.dev and the ruby-advisory-db — slots in beside bundler-audit rather than replacing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the control, not just shipping it
&lt;/h2&gt;

&lt;p&gt;The fastest way to get a security process ignored is to drop a wall of red CI checks on a team mid-sprint, so we staged it and verified adoption as deliberately as coverage. Week one, the scanners ran in &lt;strong&gt;warn-only&lt;/strong&gt; mode — reporting findings without failing the build. That surfaced the existing backlog of advisories, gave us an honest baseline instead of a nasty surprise, and let us triage and either fix or explicitly accept each item. Only then did we flip the gate to blocking, by which point there was nothing left for it to fail on. The second thing that kept adoption alive was making the checks fast and legible: bundler-audit and OSV both run in well under a minute with a warm cache, and their output points straight at the offending gem and advisory, so a failure has an obvious fix and nobody routes around it. A gate that takes ten minutes or emits inscrutable output gets bypassed, which is how a control becomes theater.&lt;/p&gt;

&lt;h2&gt;
  
  
  Residual risk / what we're still watching
&lt;/h2&gt;

&lt;p&gt;The playbook meaningfully raised the cost of a supply-chain compromise, but it does not reduce the residual to zero, and the edges are where the honesty has to be.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scanners detect &lt;em&gt;known&lt;/em&gt; vulnerabilities only.&lt;/strong&gt; A brand-new typosquat or an undisclosed backdoor has no advisory yet and passes clean. That is exactly why the human "why this gem?" review still carries load the automation cannot — the checklist is a control against the unknown-unknown, not redundant with the scanners.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signatures are not a force field.&lt;/strong&gt; Gem signing is one control among pinning, checksums, review, and CI gating — treating it as a silver bullet would be a false-confidence failure. We weight it accordingly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The lockfile diff still needs human eyes.&lt;/strong&gt; Checksum verification defuses most lockfile-injection risk, but a reviewer who reads only the &lt;code&gt;Gemfile&lt;/code&gt; and skims the machine-generated &lt;code&gt;Gemfile.lock&lt;/code&gt; can still miss a dependency quietly re-pointed. We review the lockfile diff on any dependency PR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patch-bump noise vs. coverage.&lt;/strong&gt; A blanket manual-review policy drowns the team; letting CI and scans gate patch bumps while humans review major/minor keeps signal high, but it does accept that a malicious patch release is caught by scanners rather than eyes. We watch that trade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We still depend on the registry and the advisory databases.&lt;/strong&gt; Our second line assumes RubyGems.org's first line and assumes OSV.dev and the ruby-advisory-db are current; a gap or delay in any of them is a gap in us. Two independent scanners mitigate the advisory dependency, and — because that shared line of defense runs partly on a small group's effort — we treat a recurring sponsorship of the ecosystem as risk management, not charity: the CI gate protects our repo, sponsorship protects the line in front of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a team ships Ruby, the near-miss described here is a matter of when, not if. The residual we keep watching is the undisclosed package and the lockfile change nobody reads closely — the two places where luck, not process, is still doing quiet work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;RubyGems.org — &lt;a href="https://blog.rubygems.org/2025/08/25/rubygems-security-response.html" rel="noopener noreferrer"&gt;How RubyGems.org Protects Our Community's Critical OSS Infrastructure&lt;/a&gt; (Aug 25, 2025)&lt;/li&gt;
&lt;li&gt;rubysec — &lt;a href="https://github.com/rubysec/bundler-audit" rel="noopener noreferrer"&gt;bundler-audit&lt;/a&gt; and the &lt;a href="https://github.com/rubysec/ruby-advisory-db" rel="noopener noreferrer"&gt;ruby-advisory-db&lt;/a&gt; it checks against&lt;/li&gt;
&lt;li&gt;Google — &lt;a href="https://github.com/google/osv-scanner" rel="noopener noreferrer"&gt;OSV-Scanner&lt;/a&gt;, backed by &lt;a href="https://osv.dev/" rel="noopener noreferrer"&gt;osv.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Bundler docs — &lt;a href="https://bundler.io/man/bundle-lock.1.html" rel="noopener noreferrer"&gt;&lt;code&gt;bundle lock --add-checksums&lt;/code&gt;&lt;/a&gt; (Bundler 2.6 checksum verification)&lt;/li&gt;
&lt;li&gt;wilburhimself — &lt;a href="https://github.com/wilburhimself/gem_guard" rel="noopener noreferrer"&gt;GemGuard: typosquat detection + SBOM for Ruby&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/rubygems-supply-chain-security?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=rubygems-supply-chain-security" rel="noopener noreferrer"&gt;A full Ruby supply-chain playbook&lt;/a&gt; — a useful third-party account of the Bundler hardening settings and legacy-app rollout FAQ.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>Governance Risk Is Supply-Chain Risk: Auditing Every Dependency After the RubyGems Hostile Takeover</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Mon, 10 Aug 2026 08:12:28 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/governance-risk-is-supply-chain-risk-auditing-every-dependency-after-the-rubygems-hostile-takeover-2155</link>
      <guid>https://dev.to/gaberialsofie/governance-risk-is-supply-chain-risk-auditing-every-dependency-after-the-rubygems-hostile-takeover-2155</guid>
      <description>&lt;h2&gt;
  
  
  The exposure
&lt;/h2&gt;

&lt;p&gt;In September 2025 the people who built and ran RubyGems and Bundler lost administrative access to the infrastructure they maintained, and several publicly quit. One side called it a hostile takeover; the other called it responsible stewardship. The verdict is not the point. The exposure is: infrastructure serving billions of downloads a month — the infrastructure every &lt;code&gt;bundle install&lt;/code&gt;, every deploy, every CI run reaches into — demonstrated that control over the packages your builds depend on can change hands overnight.&lt;/p&gt;

&lt;p&gt;Framed as a security problem, the &lt;strong&gt;rubygems hostile takeover&lt;/strong&gt; is a governance-risk event, and governance risk is supply-chain risk that no vulnerability scanner will ever flag. A CVE scanner tells you a dependency has a known flaw; it says nothing about whether that dependency's maintainers just walked out or its publishing rights just moved to someone you did not vet. My company does not contribute to RubyGems — we only consume it, which means we had silently accepted a dependency on both the packages and the people, and the crisis made that dependency visible. &lt;a href="https://www.theregister.com/2025/09/25/open_source_to_closed_doors/" rel="noopener noreferrer"&gt;The Register covered the control fight as it erupted&lt;/a&gt;, Ruby Central later published its own &lt;a href="https://rubycentral.org/news/rubygems-fracture-incident-report/" rel="noopener noreferrer"&gt;RubyGems Fracture Incident Report&lt;/a&gt; owning the execution mistakes, and I worked through &lt;a href="https://dorokhovich.com/blog/ruby-crisis-takeover?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=ruby-crisis-takeover" rel="noopener noreferrer"&gt;a thorough third-party first-person timeline of what one engineer changed in response →&lt;/a&gt; while treating our own exposure as a fire drill rather than a hot take.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat model
&lt;/h2&gt;

&lt;p&gt;The timeline matters only insofar as it names the threats. Between September 9 and 19, the GitHub org was renamed, maintainers were removed, administrative access to the RubyGems, Bundler, and RubyGems.org teams was revoked — including control of the &lt;code&gt;bundler&lt;/code&gt; and &lt;code&gt;rubygems-update&lt;/code&gt; gems themselves — and the creator of Bundler announced the team was effectively finished. From that, the concrete threats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Publishing rights changing hands overnight.&lt;/strong&gt; Control over the exact packages your builds pull can move to a different party without your consent or knowledge. If your security model assumes the &lt;em&gt;right&lt;/em&gt; people hold the keys, it is not a model — it is a hope. Ruby Central's own &lt;a href="https://rubycentral.org/news/rubygems-fracture-incident-report/" rel="noopener noreferrer"&gt;incident report&lt;/a&gt; concedes the foundation "lacked the structural ability to make this change directly," i.e. an informal power structure that a single actor could reconfigure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintainer-exodus as a failure mode.&lt;/strong&gt; The people who understand a critical dependency's internals can all leave at once. A healthy maintainer team is itself a dependency, and its collapse is a real failure mode — a bus-factor risk at ecosystem scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registry availability during the incident.&lt;/strong&gt; If RubyGems.org had gone dark or a gem had been yanked mid-fracture, builds that fetch from upstream at deploy time would have failed. Reachability of a third party you do not control sits on your critical path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Funding-pressure-driven consolidation.&lt;/strong&gt; Reporting since made clear how much funding pressure sat underneath the fight — &lt;a href="https://www.theregister.com/2025/09/25/open_source_to_closed_doors/" rel="noopener noreferrer"&gt;The Register relayed developer Joel Drapper's allegation that Shopify pressed for consolidated control&lt;/a&gt; of the &lt;code&gt;bundler&lt;/code&gt; and &lt;code&gt;rubygems-update&lt;/code&gt; gems under threat of pulled funding. Commercial leverage over volunteer infrastructure is a governance attack surface in its own right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ecosystem split.&lt;/strong&gt; The ousted maintainers did not disappear; Martin Emde &lt;a href="https://martinemde.com/2025/10/05/announcing-gem-coop.html" rel="noopener noreferrer"&gt;announced The Gem Cooperative (gem.coop)&lt;/a&gt;, a community-run mirror, which &lt;a href="https://www.theregister.com/2025/10/06/gem_cooperative/" rel="noopener noreferrer"&gt;The Register covered as the maintainers' new home&lt;/a&gt;. "Which registry does our &lt;code&gt;bundle install&lt;/code&gt; actually hit" went from a question I would have shrugged at to a line item.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The organizing principle: reproducibility is resilience. If a build needs the internet — and a particular governance arrangement — to be calm in order to succeed, it will fail when they are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controls we added
&lt;/h2&gt;

&lt;p&gt;I gave the drill one week and one goal: make sure a governance blowup or a bad actor upstream could neither stop us shipping nor poison our builds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 1 — freeze the ground we stand on
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Committed and audited &lt;code&gt;Gemfile.lock&lt;/code&gt;&lt;/strong&gt; so every version and platform is pinned, not floating into production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vendored the critical path.&lt;/strong&gt; &lt;a href="https://bundler.io/man/bundle-cache.1.html" rel="noopener noreferrer"&gt;&lt;code&gt;bundle cache&lt;/code&gt;&lt;/a&gt; copies every &lt;code&gt;.gem&lt;/code&gt; into &lt;code&gt;vendor/cache&lt;/code&gt; and, per the docs, makes future installs "use the gems in the cache in preference to the ones on rubygems.org"; &lt;code&gt;bundle config set cache_all true&lt;/code&gt; keeps git and path gems there too. If RubyGems.org went dark or a gem was yanked mid-incident, deploys still work from the cache. This directly retires the registry-availability threat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pinned git dependencies to commit SHAs&lt;/strong&gt;, never bare branch names. A branch can be force-pushed under new ownership; a SHA cannot be quietly swapped — which addresses the "control changed hands" threat at the integrity level.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Control 2 — inventory governance risk, not just CVE risk
&lt;/h3&gt;

&lt;p&gt;Because scanners like &lt;a href="https://github.com/rubysec/bundler-audit" rel="noopener noreferrer"&gt;bundler-audit&lt;/a&gt; (which checks &lt;code&gt;Gemfile.lock&lt;/code&gt; against the ruby-advisory-db) are silent on governance, I built a checklist and ran our top dependencies through it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who actually controls publishing rights to this package today?&lt;/li&gt;
&lt;li&gt;How many maintainers — is it a bus-factor of one?&lt;/li&gt;
&lt;li&gt;Has ownership or org name changed recently?&lt;/li&gt;
&lt;li&gt;Is there a funded, formal body behind it, or informal goodwill?&lt;/li&gt;
&lt;li&gt;If it vanished tomorrow, what is our replacement path?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last question turns a vague anxiety into a ranked list of things to fix, which is the difference between worrying about governance risk and actually managing it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 3 — reduce the surface
&lt;/h3&gt;

&lt;p&gt;Fewer upstreams means fewer governance fires can burn you. I pruned unused and transitive bloat and added a "why this dependency, and who is behind it?" note to code review for anything new — the same hygiene the ecosystem needs at the macro level, applied to our own repo. Least privilege for dependencies: adopt only what you can justify and account for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the controls, not just shipping them
&lt;/h2&gt;

&lt;p&gt;A control the team does not believe in gets removed the first time it is inconvenient, so the verification here was as much organizational as technical. I made the risk concrete with three questions borrowed from the crisis: if the maintainers of our single most critical dependency all quit tomorrow, what breaks and how fast; who can publish a new version of that package right now, and do we trust the process that grants them that power; and if that package got hijacked and shipped a malicious release, would our pipeline catch it before production. For most dependencies the honest first answer was "we don't know," and "we don't know" is the actual risk — so the checklist and the vendoring exist to convert those unknowns into documented, tested answers. The vendoring in particular is verifiable in the most direct way: a build run with upstream network access blocked either succeeds from &lt;code&gt;vendor/cache&lt;/code&gt; or it does not, and ours does. Unexpectedly, "we are hardening against a real, recent, widely-reported failure in our supply chain" turned out to be the easiest resilience work I have ever had to justify to leadership.&lt;/p&gt;

&lt;h2&gt;
  
  
  Residual risk / what we're still watching
&lt;/h2&gt;

&lt;p&gt;The drill meaningfully reduced our exposure to an upstream governance shock, but it did not make us independent of the ecosystem, and the edges deserve honesty.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vendored gems drift and stale.&lt;/strong&gt; &lt;code&gt;vendor/cache&lt;/code&gt; protects availability, but a frozen cache also stops receiving security updates; a vulnerability disclosed in a vendored gem is now on us to notice and refresh. Vendoring trades an availability risk for a patch-currency obligation, so we pair it with scheduled re-audits rather than treating the cache as done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scanners still cannot see governance.&lt;/strong&gt; The checklist is a manual control against a threat no tool flags, which means it decays unless it is re-run. We re-inventory the top dependencies periodically, because ownership and maintainer health change quietly between reviews.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ecosystem split is unresolved.&lt;/strong&gt; With gem.coop now standing alongside RubyGems.org, "which registry are we actually trusting, and what is its provenance" is an ongoing decision, not a settled one. We are watching how it resolves without letting our builds &lt;em&gt;depend&lt;/em&gt; on it resolving well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transitive dependencies we do not control.&lt;/strong&gt; We can inventory our direct dependencies' governance, but the long tail of transitive gems carries the same risk with far less visibility. That surface is the hardest residual to close, and it is where we assume the next surprise lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our second line still assumes a first line.&lt;/strong&gt; Vendoring and pinning protect us from upstream &lt;em&gt;disappearing&lt;/em&gt;, not from a malicious release we vendor before anyone notices it is malicious — that gap is covered by scanning and review, which are themselves only as current as their data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The durable lesson is that you depend on people, not just packages, and that a small group quietly holds up infrastructure the whole ecosystem leans on. When that group fractures, everyone downstream discovers exactly how much they were depending on trust they never examined. What we keep watching is that trust — deliberately, on calm afternoons, rather than during the next incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Register — &lt;a href="https://www.theregister.com/2025/09/25/open_source_to_closed_doors/" rel="noopener noreferrer"&gt;Open source to closed doors: RubyGems control fight erupts&lt;/a&gt; (Sep 25, 2025)&lt;/li&gt;
&lt;li&gt;Ruby Central — &lt;a href="https://rubycentral.org/news/rubygems-fracture-incident-report/" rel="noopener noreferrer"&gt;RubyGems Fracture Incident Report&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Martin Emde — &lt;a href="https://martinemde.com/2025/10/05/announcing-gem-coop.html" rel="noopener noreferrer"&gt;Announcing The Gem Cooperative (gem.coop)&lt;/a&gt; (Oct 5, 2025)&lt;/li&gt;
&lt;li&gt;The Register — &lt;a href="https://www.theregister.com/2025/10/06/gem_cooperative/" rel="noopener noreferrer"&gt;Kicked from RubyGems, maintainers forge new home at Gem Cooperative&lt;/a&gt; (Oct 6, 2025)&lt;/li&gt;
&lt;li&gt;Bundler docs — &lt;a href="https://bundler.io/man/bundle-cache.1.html" rel="noopener noreferrer"&gt;&lt;code&gt;bundle cache&lt;/code&gt;: vendoring gems into &lt;code&gt;vendor/cache&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;rubysec — &lt;a href="https://github.com/rubysec/bundler-audit" rel="noopener noreferrer"&gt;bundler-audit: scan &lt;code&gt;Gemfile.lock&lt;/code&gt; against the ruby-advisory-db&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/ruby-crisis-takeover?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=ruby-crisis-takeover" rel="noopener noreferrer"&gt;A field-notes dependency-audit checklist from the week of the crisis&lt;/a&gt; — a useful third-party account of the same governance-risk drill applied to a real repo.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cybersecurity</category>
      <category>devops</category>
      <category>infrastructure</category>
      <category>security</category>
    </item>
    <item>
      <title>When One Keycloak Node Is a Single Point of Failure: A Keycloak Cluster Nginx Threat Model for Zero-Downtime Upgrades</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:50:41 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/when-one-keycloak-node-is-a-single-point-of-failure-a-keycloak-cluster-nginx-threat-model-for-dg</link>
      <guid>https://dev.to/gaberialsofie/when-one-keycloak-node-is-a-single-point-of-failure-a-keycloak-cluster-nginx-threat-model-for-dg</guid>
      <description>&lt;h2&gt;
  
  
  The exposure
&lt;/h2&gt;

&lt;p&gt;We ran Keycloak the way most teams start: one node, one VM, "we'll cluster it later." Later arrived as a 40-minute outage. A routine security patch required a restart, the node came back with a config typo, and for the duration nobody in the company could authenticate to anything — not the admin tools, not the customer portal, not the internal apps that all trusted it.&lt;/p&gt;

&lt;p&gt;It is worth being precise about what class of exposure that is, because "make it not crash" is the wrong framing. Availability is a security property — it sits alongside confidentiality and integrity in the CIA triad — and a single identity provider is a single point of failure whose blast radius is every system that delegates authentication to it. When that one node is down, the failure is not "one service is slow"; it is that the entire organization loses the ability to prove who anyone is. A &lt;strong&gt;keycloak cluster nginx&lt;/strong&gt; design is the control that bounds that blast radius: the postmortem action item was blunt — Keycloak must survive a node loss and support rolling upgrades so that a routine patch is never again an org-wide authentication outage. I did not want to invent an HA design from first principles, so I worked from &lt;a href="https://dorokhovich.com/blog/keycloak-nginx-cluster?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=keycloak-nginx-cluster" rel="noopener noreferrer"&gt;a thorough third-party guide to running Keycloak behind Nginx as a real cluster →&lt;/a&gt; alongside the official docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat model
&lt;/h2&gt;

&lt;p&gt;Framing the outage as a security problem dictated what we actually had to build.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Total authentication loss on node failure.&lt;/strong&gt; With one node, any crash, bad config, or patch takes down authentication for every dependent system simultaneously. The control objective is that a single node loss is survivable and invisible to users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patch and upgrade as forced outage.&lt;/strong&gt; If the only way to apply a security patch is to restart the sole node, then staying patched and staying available are in direct conflict — which pressures teams to defer security updates. Rolling upgrades remove that conflict, so the cluster is also a patch-hygiene control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session loss as a re-authentication storm.&lt;/strong&gt; If sessions live only on the failed node, a failover forces every active user to log in again at once. Beyond the user impact, a synchronized re-auth burst is itself a load event against the surviving node. Session state must survive a node loss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy misconfiguration as an integrity risk.&lt;/strong&gt; Putting a reverse proxy in front means Keycloak now trusts forwarded headers to build redirect URLs and evaluate the request origin. Mishandled &lt;code&gt;X-Forwarded-*&lt;/code&gt; headers produce wrong issuer/redirect behavior — a correctness-and-trust problem, not just a cosmetic one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Controls we added
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Control 1 — two nodes with replicated session state
&lt;/h3&gt;

&lt;p&gt;Clustering adds redundancy, and &lt;strong&gt;Infinispan&lt;/strong&gt; provides the distributed caches that replicate sessions and tokens across nodes, so a user logged in on node 1 stays logged in if node 1 disappears (&lt;a href="https://www.keycloak.org/server/caching" rel="noopener noreferrer"&gt;Keycloak's distributed-cache guide&lt;/a&gt; documents the session, token, and authentication-session caches). Discovery is the first real decision: on VMs, JGroups with &lt;code&gt;JDBC_PING&lt;/code&gt; (nodes find each other through a shared database table — now Keycloak's &lt;a href="https://www.keycloak.org/server/caching" rel="noopener noreferrer"&gt;default discovery mechanism&lt;/a&gt;); on Kubernetes, &lt;code&gt;KUBE_PING&lt;/code&gt; via the API with a StatefulSet and headless Service. We were on VMs with a shared Postgres, so we started on JGroups/&lt;code&gt;JDBC_PING&lt;/code&gt; and planned a later migration to &lt;code&gt;KUBE_PING&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 2 — Nginx as the boundary, configured deliberately
&lt;/h3&gt;

&lt;p&gt;Nginx terminates TLS, hides the internal cluster topology, and load-balances across the nodes — the reverse-proxy pattern documented for &lt;a href="https://docs.nginx.com/nginx/admin-guide/load-balancer/http-load-balancer/" rel="noopener noreferrer"&gt;HTTP load balancing&lt;/a&gt;. The Keycloak side uses the modern Quarkus proxy flags, which are the crux of running behind a proxy safely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="s"&gt;start --http-enabled=true&lt;/span&gt;
        &lt;span class="s"&gt;--hostname-strict=false&lt;/span&gt;
        &lt;span class="s"&gt;--proxy-headers=xforwarded&lt;/span&gt;
        &lt;span class="s"&gt;--metrics-enabled=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--proxy-headers=xforwarded&lt;/code&gt; is the line people miss; the legacy &lt;code&gt;PROXY_ADDRESS_FORWARDING&lt;/code&gt; env var is gone. Keycloak's &lt;a href="https://www.keycloak.org/server/reverseproxy" rel="noopener noreferrer"&gt;reverse-proxy guide&lt;/a&gt; is explicit that &lt;code&gt;xforwarded&lt;/code&gt; enables parsing of the &lt;code&gt;X-Forwarded-*&lt;/code&gt; headers and that you relax &lt;code&gt;--hostname-strict&lt;/code&gt; (or pin &lt;code&gt;--hostname&lt;/code&gt;) so redirect URLs are built correctly. Getting this wrong produces "invalid issuer" and redirect errors — and, more subtly, means Keycloak may be trusting client-supplied headers it should not, which is why the proxy must be the only thing setting them.&lt;/p&gt;

&lt;p&gt;The Nginx config matches those flags — TLS termination, forwarded headers, passive health checks, and sticky sessions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;keycloak_http&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;ip_hash&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                      &lt;span class="c1"&gt;# simple session affinity&lt;/span&gt;
  &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;keycloak-1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt; &lt;span class="s"&gt;max_fails=3&lt;/span&gt; &lt;span class="s"&gt;fail_timeout=10s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;keycloak-2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt; &lt;span class="s"&gt;max_fails=3&lt;/span&gt; &lt;span class="s"&gt;fail_timeout=10s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt; &lt;span class="s"&gt;http2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;ssl_protocols&lt;/span&gt; &lt;span class="s"&gt;TLSv1.2&lt;/span&gt; &lt;span class="s"&gt;TLSv1.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://keycloak_http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt;              &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt;   &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Host&lt;/span&gt;  &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Port&lt;/span&gt;  &lt;span class="nv"&gt;$server_port&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;Passive health checks (&lt;code&gt;max_fails=3 fail_timeout=10s&lt;/code&gt;) evict a bad node after a few failures. On affinity: &lt;code&gt;ip_hash&lt;/code&gt; hashes on the first three octets of the client's IPv4 address (&lt;a href="https://nginx.org/en/docs/http/ngx_http_upstream_module.html" rel="noopener noreferrer"&gt;nginx &lt;code&gt;ngx_http_upstream_module&lt;/code&gt;&lt;/a&gt;), which is coarse — everyone behind one corporate NAT lands on the same node. Cookie-based stickiness on the &lt;code&gt;AUTH_SESSION_ID&lt;/code&gt; cookie pins each browser session to the node that owns it; we started with &lt;code&gt;ip_hash&lt;/code&gt; for simplicity and moved to &lt;code&gt;AUTH_SESSION_ID&lt;/code&gt; affinity as traffic grew.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 3 — hardening the now-critical identity tier
&lt;/h3&gt;

&lt;p&gt;Concentrating all authentication behind a cluster makes that cluster the highest-value asset on the network, so it earns defense in depth beyond mere redundancy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pin &lt;code&gt;--hostname&lt;/code&gt; so external URLs are consistent across nodes and cannot be influenced by a spoofed Host header.&lt;/li&gt;
&lt;li&gt;Enable &lt;code&gt;--metrics-enabled=true&lt;/code&gt;, scrape with Prometheus, and alert on 5xx, login errors, and session spikes — a login-error spike is a security signal (credential stuffing, brute force), not just an ops one, so lost telemetry is lost detection.&lt;/li&gt;
&lt;li&gt;Run managed HA Postgres with DB TLS and tuned pools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lock the admin console behind IP/VPN, rotate admin credentials, and require 2FA for admins&lt;/strong&gt; — the admin console is a full realm-compromise surface and does not belong on the open internet.&lt;/li&gt;
&lt;li&gt;On Kubernetes, use a StatefulSet, a headless Service, and NGINX Ingress with correct timeout/header annotations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verifying the control, not just shipping it
&lt;/h2&gt;

&lt;p&gt;An HA design is a hypothesis until failure is demonstrated on demand — assuming HA works because the diagram says so is how you end up with a more expensive single point of failure. Before pointing production DNS at the cluster we ran deliberate failure drills: with a colleague mid-session in the admin console, I killed &lt;code&gt;keycloak-1&lt;/code&gt; outright; Nginx's passive health check marked it down within &lt;code&gt;fail_timeout&lt;/code&gt;, traffic shifted to &lt;code&gt;keycloak-2&lt;/code&gt;, and because Infinispan had replicated the session, the colleague never saw a login prompt. We repeated it in reverse, then killed a node during an active token refresh. Only after all three drills passed did we cut over. Two lessons from that exercise are worth keeping: session affinity and replication are different controls (without stickiness, users bounced between nodes mid-login and hit failures in the brief window before a session propagated; replication is the safety net if a node actually dies — you want both), and JGroups is fussy about networking (our first attempt only half-formed a cluster because a firewall rule silently dropped discovery traffic, so confirm the cluster actually forms rather than assuming it did).&lt;/p&gt;

&lt;h2&gt;
  
  
  Residual risk / what we're still watching
&lt;/h2&gt;

&lt;p&gt;Clustering bounded the blast radius of a node loss, but it introduced new failure modes and a more valuable target, and naming them is the point.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The cluster is now the highest-value asset.&lt;/strong&gt; Redundancy protects availability, but it does not protect against a compromise of the identity tier itself — a stolen admin credential or a Keycloak CVE is now a whole-org event. That is why the admin console is VPN-gated with mandatory 2FA, and why we track Keycloak advisories directly rather than waiting for a bump.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cluster-formation and discovery are silent failure modes.&lt;/strong&gt; JGroups needs clean ports and coherent discovery, and inter-node latency degrades replication. A cluster that silently fails to form looks healthy until a failover drops sessions. We monitor cluster membership as a first-class signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forwarded-header trust boundary.&lt;/strong&gt; Because Keycloak now trusts &lt;code&gt;X-Forwarded-*&lt;/code&gt; from the proxy, the proxy must be the only source of those headers; a path that lets a client inject them would let it influence issuer/redirect behavior. We treat the proxy configuration as security-sensitive and reviewed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session-affinity coarseness under NAT.&lt;/strong&gt; &lt;code&gt;ip_hash&lt;/code&gt; concentrates large NATed populations onto one node — an availability and load hot-spot; &lt;code&gt;AUTH_SESSION_ID&lt;/code&gt; affinity mitigates it, and we watch per-node session distribution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-AZ is still open.&lt;/strong&gt; Two nodes in one availability zone still share an AZ-failure fate. We are finishing the migration to &lt;code&gt;KUBE_PING&lt;/code&gt; on Kubernetes and spreading nodes multi-AZ so a zone loss is as boring as a node restart.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The morning after go-live we patched a node in the middle of the workday and nobody noticed — which was the entire point. The residual work is to keep proving that property holds as the topology changes, and to keep treating the identity tier as the critical asset a whole organization now depends on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.keycloak.org/server/reverseproxy" rel="noopener noreferrer"&gt;Keycloak — Configuring a reverse proxy&lt;/a&gt; — the &lt;code&gt;proxy-headers&lt;/code&gt; values and hostname flags, straight from the source.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.keycloak.org/server/caching" rel="noopener noreferrer"&gt;Keycloak — Configuring distributed caches&lt;/a&gt; — how Infinispan replicates sessions and how nodes discover each other.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.nginx.com/nginx/admin-guide/load-balancer/http-load-balancer/" rel="noopener noreferrer"&gt;NGINX — HTTP Load Balancing&lt;/a&gt; — upstreams, passive health checks, and session persistence.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://nginx.org/en/docs/http/ngx_http_upstream_module.html" rel="noopener noreferrer"&gt;nginx &lt;code&gt;ngx_http_upstream_module&lt;/code&gt;&lt;/a&gt; — the &lt;code&gt;ip_hash&lt;/code&gt; directive and its IPv4 hashing behavior.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/keycloak-nginx-cluster?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=keycloak-nginx-cluster" rel="noopener noreferrer"&gt;A Keycloak + Nginx clustering walkthrough&lt;/a&gt; — a useful third-party account of the Docker lab, production nginx.conf, and hardening checklist this analysis draws on.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>infrastructure</category>
      <category>security</category>
    </item>
    <item>
      <title>Deleting Hand-Rolled Auth From a Next.js App: A Keycloak Nextjs Threat Model and NextAuth Cutover</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Sat, 08 Aug 2026 17:16:55 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/deleting-hand-rolled-auth-from-a-nextjs-app-a-keycloak-nextjs-threat-model-and-nextauth-cutover-1n40</link>
      <guid>https://dev.to/gaberialsofie/deleting-hand-rolled-auth-from-a-nextjs-app-a-keycloak-nextjs-threat-model-and-nextauth-cutover-1n40</guid>
      <description>&lt;h2&gt;
  
  
  The exposure
&lt;/h2&gt;

&lt;p&gt;Every codebase has one module nobody wants their name on. Ours was &lt;code&gt;lib/auth.ts&lt;/code&gt; — a homegrown JWT system in our Next.js app that had been "good enough for the MVP" three years earlier. It signed its own tokens, hand-rolled refresh-token rotation, and stored sessions in a way two engineers actively disagreed about. Exactly one person understood the rotation logic, and when he took vacation we froze all auth-adjacent work out of fear.&lt;/p&gt;

&lt;p&gt;That is a security exposure before it is an engineering one. Hand-rolled authentication concentrates several of the highest-consequence failure modes in software into code that is rarely reviewed and understood by one person. A &lt;strong&gt;keycloak nextjs&lt;/strong&gt; integration was attractive not because Keycloak is fashionable but because delegating authentication moves password storage, MFA, token signing, and refresh — the parts where a subtle bug is a breach, not a bug — out of our codebase entirely. The trigger was concrete: a security review flagged four separate issues in that one file. Rather than patch a system we did not trust, we ripped it out and stood on an identity provider we already ran, leaning on the &lt;a href="https://authjs.dev/getting-started/providers/keycloak" rel="noopener noreferrer"&gt;official Auth.js Keycloak provider&lt;/a&gt; and Next.js &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/route" rel="noopener noreferrer"&gt;App Router route handlers&lt;/a&gt;. The result was about forty lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat model
&lt;/h2&gt;

&lt;p&gt;Being specific about what a hand-rolled auth module gets you exposed to is what justified deleting it rather than patching it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-implemented token cryptography.&lt;/strong&gt; A module that signs and verifies its own JWTs is one weak-algorithm choice or one missing signature check away from token forgery. This is the class of defect where "it works" and "it is secure" look identical from the outside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh-token rotation errors.&lt;/strong&gt; Refresh logic understood by a single engineer is fragile in the worst place: a bug can either lock users out (availability) or fail to invalidate a stolen refresh token (a persistent-access foothold). The four review findings clustered here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ambiguous session handling.&lt;/strong&gt; Two engineers disagreeing on how sessions are stored is a session-fixation and session-invalidation risk waiting to surface — you cannot invalidate a session cleanly if you cannot agree on where it lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Knowledge concentration as a security risk.&lt;/strong&gt; Auth that only one person understands cannot be safely reviewed, patched, or incident-handled. Bus factor one on the credential path is itself a finding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The decision: reduce our attack surface by owning as little authentication code as possible, and let Keycloak — whose entire job is to get password storage, MFA, and token signing right — own the security-critical parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controls we added
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Control 1 — delegate authentication, own almost none of it
&lt;/h3&gt;

&lt;p&gt;NextAuth.js (branded Auth.js in v5; the &lt;code&gt;next-auth&lt;/code&gt; package and Keycloak provider are the same) ships a first-party Keycloak provider. You declare a provider, point it at your realm's issuer URL, and it handles the Authorization Code flow, callbacks, and session cookies. The security-relevant property is subtraction: the token exchange, password handling, and MFA all move behind Keycloak, so the surface where our own code can be wrong shrinks to configuration. I cross-referenced the official provider docs against &lt;a href="https://dorokhovich.com/blog/keycloak-nextjs-integration?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=keycloak-nextjs-integration" rel="noopener noreferrer"&gt;a thorough third-party walkthrough of the NextAuth-Keycloak wiring →&lt;/a&gt; while doing this.&lt;/p&gt;

&lt;p&gt;One trap that had burned us: do not mix Pages Router examples into an App Router app. Most stale tutorials use &lt;code&gt;NextApiRequest&lt;/code&gt; and &lt;code&gt;pages/api/auth&lt;/code&gt;; in App Router you export &lt;code&gt;GET&lt;/code&gt;/&lt;code&gt;POST&lt;/code&gt; from a &lt;code&gt;route.ts&lt;/code&gt;, &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/route" rel="noopener noreferrer"&gt;per the Next.js file convention&lt;/a&gt;. Getting that straight up front is a correctness control — a subtly wrong handler on the auth path is not a place to be copy-pasting.&lt;/p&gt;

&lt;p&gt;The whole integration lives at &lt;code&gt;app/api/auth/[...nextauth]/route.ts&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;NextAuth&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;next-auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;KeycloakProvider&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;next-auth/providers/keycloak&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NextAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;KeycloakProvider&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="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_ID&lt;/span&gt;&lt;span class="o"&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="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&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_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/realms/&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_REALM&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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="na"&gt;secret&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;NEXTAUTH_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;session&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;jwt&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;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;issuer&lt;/code&gt; must include the realm (&lt;code&gt;/realms/&amp;lt;name&amp;gt;&lt;/code&gt;) — the &lt;a href="https://authjs.dev/getting-started/providers/keycloak" rel="noopener noreferrer"&gt;provider docs&lt;/a&gt; are blunt about it, and a wrong issuer path is a common and security-relevant misconfiguration because it changes which authority you are actually trusting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 2 — client configuration as an enforced allow-list
&lt;/h3&gt;

&lt;p&gt;In Keycloak we created a &lt;code&gt;nextjs-client&lt;/code&gt; (confidential, with a client secret) and treated two fields as controls, not preferences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Valid Redirect URIs:&lt;/strong&gt; &lt;code&gt;http://localhost:3000/api/auth/callback/*&lt;/code&gt;, matched character for character including protocol and trailing slash. A loose or wildcard redirect is an open-redirect and token-exfiltration vector, so it is scoped to exactly where NextAuth listens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access type:&lt;/strong&gt; confidential for the server-side surface, holding a secret; public clients get PKCE instead. Getting this classification wrong is a genuine weakness, not a style choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Control 3 — hardening we adopted deliberately
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PKCE&lt;/strong&gt; for public clients, always — &lt;a href="https://oauth.net/2/pkce/" rel="noopener noreferrer"&gt;RFC 7636&lt;/a&gt; exists precisely to stop authorization-code interception on clients that cannot hold a secret; modern Auth.js enables it for OIDC providers by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;KEYCLOAK_CLIENT_SECRET&lt;/code&gt; and &lt;code&gt;NEXTAUTH_SECRET&lt;/code&gt;&lt;/strong&gt; generated per environment (&lt;code&gt;openssl rand -base64 32&lt;/code&gt;), pushed through the secrets pipeline, never committed, and rotated on a cadence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS end-to-end&lt;/strong&gt; with secure cookies in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit session strategy&lt;/strong&gt; (&lt;code&gt;jwt&lt;/code&gt;, chosen on purpose) rather than silently mixing &lt;code&gt;jwt&lt;/code&gt; and &lt;code&gt;database&lt;/code&gt; sessions, which is a classic footgun with real invalidation consequences.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Control 4 — a cutover that never locked anyone out
&lt;/h3&gt;

&lt;p&gt;Swapping authentication on a live product is a high-risk change, so we staged it to stay defensible throughout. We shipped the Keycloak route behind a feature flag and ran it in parallel with the old system for a week, dogfooding internally. We migrated user identities into Keycloak ahead of time and mapped them by email so nobody had to re-register — a re-registration flow is itself a phishing pretext we did not want to create. We kept the old &lt;code&gt;lib/auth.ts&lt;/code&gt; in the tree but unreferenced for one release as a rollback escape hatch, then deleted it once the dashboards stayed green — a dormant second auth system is standing attack surface, so it did not linger. The cutover was a single flag toggle at low traffic, and the rollback plan was toggling it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the control, not just shipping it
&lt;/h2&gt;

&lt;p&gt;The failure that validated the approach was a redirect loop that appeared the instant we deployed to staging: no useful error. The cause was a mismatched redirect URI down to a trailing slash the Keycloak Valid Redirect URI did not have. That is worth internalizing as a security habit, not just a debugging tip — redirect-URI matching is the control that keeps authorization codes from going to the wrong place, so when it is strict enough to break on a trailing slash it is also strict enough to reject an attacker's callback. We aligned it and added the check to our deploy runbook. Because authentication now lives behind Keycloak, "does login work" is answerable by driving the flow against the provider rather than reading our own crypto.&lt;/p&gt;

&lt;h2&gt;
  
  
  Residual risk / what we're still watching
&lt;/h2&gt;

&lt;p&gt;Deleting the hand-rolled module removed a class of self-inflicted crypto and rotation defects, but it relocated risk into configuration and into a dependency.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access-token refresh is the next sensitive control.&lt;/strong&gt; Keycloak's default access-token lifespan is five minutes, so refresh matters sooner than teams expect. Refreshing OAuth tokens inside the &lt;code&gt;jwt&lt;/code&gt;/&lt;code&gt;session&lt;/code&gt; callbacks — the &lt;a href="https://authjs.dev/guides/refresh-token-rotation" rel="noopener noreferrer"&gt;Auth.js Refresh Token Rotation guide&lt;/a&gt; is the canonical approach — must handle a failed refresh by forcing re-authentication rather than silently serving a stale identity. This is the piece we are implementing most carefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back-channel logout is a real gap until we close it.&lt;/strong&gt; Today, signing out of Keycloak does not yet invalidate the Next.js session everywhere. Until back-channel logout is wired, a revoked or logged-out user can retain a valid app session until it expires — a bounded but genuine residual window we are actively tracking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect-URI and secret drift.&lt;/strong&gt; A later loosening of the redirect allow-list, or a leaked/committed &lt;code&gt;NEXTAUTH_SECRET&lt;/code&gt; or client secret, quietly reintroduces exposure. Both live in reviewed configuration and rotation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keycloak is now a dependency on the login path.&lt;/strong&gt; We traded code we owned and distrusted for a system we must keep patched, monitored, and available. That is the right trade, but it makes Keycloak's own security posture part of ours, and we track its advisories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role-based access mapping.&lt;/strong&gt; As we map Keycloak realm roles into the &lt;code&gt;jwt&lt;/code&gt;/&lt;code&gt;session&lt;/code&gt; callbacks for authorization, each mapping is a new place a claim can be misread into wrong access, so those callbacks get the same scrutiny the token logic does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The net effect is that the scariest authentication code we owned is gone, the parts that are hard to get right now live in a system built to get them right, and the remaining risks are named, bounded, and on a list — chiefly refresh handling and back-channel logout — rather than concentrated in one engineer's head.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://authjs.dev/getting-started/providers/keycloak" rel="noopener noreferrer"&gt;Auth.js — Keycloak provider&lt;/a&gt; — the official provider config, issuer/realm rules, and client-authentication setup&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/route" rel="noopener noreferrer"&gt;Next.js — Route Handlers (&lt;code&gt;route.js&lt;/code&gt;)&lt;/a&gt; — App Router file convention for &lt;code&gt;GET&lt;/code&gt;/&lt;code&gt;POST&lt;/code&gt; handlers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://authjs.dev/guides/refresh-token-rotation" rel="noopener noreferrer"&gt;Auth.js — Refresh Token Rotation&lt;/a&gt; — persisting and refreshing OAuth tokens in the jwt/session callbacks&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://oauth.net/2/pkce/" rel="noopener noreferrer"&gt;PKCE for OAuth 2.0 (RFC 7636)&lt;/a&gt; — why public clients need it&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.keycloak.org/securing-apps/oidc-layers" rel="noopener noreferrer"&gt;Keycloak — Securing applications and services with OpenID Connect&lt;/a&gt; — OIDC endpoint and integration reference&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/keycloak-nextjs-integration?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=keycloak-nextjs-integration" rel="noopener noreferrer"&gt;An end-to-end NextAuth + Keycloak writeup&lt;/a&gt; — a useful third-party account of the full client setup, env vars, the App Router route handler, and the hardening checklist.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>nextjs</category>
      <category>security</category>
    </item>
    <item>
      <title>Collapsing 60 Trust Boundaries Into One: A Keycloak Identity Federation Threat Model and 90-Day Rollout</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Fri, 07 Aug 2026 08:53:16 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/collapsing-60-trust-boundaries-into-one-a-keycloak-identity-federation-threat-model-and-90-day-o1a</link>
      <guid>https://dev.to/gaberialsofie/collapsing-60-trust-boundaries-into-one-a-keycloak-identity-federation-threat-model-and-90-day-o1a</guid>
      <description>&lt;h2&gt;
  
  
  The exposure
&lt;/h2&gt;

&lt;p&gt;Through two acquisitions we had accumulated three separate Active Directories, a bolt-on SSO product, and roughly sixty applications each with its own login page. From a security standpoint the helpdesk ticket volume was a symptom; the disease was the identity architecture underneath it. Every application stored its own credentials, every user carried a dozen of them, and — because remembering a dozen is impossible — people reused the same weak password across systems that had no shared trust boundary.&lt;/p&gt;

&lt;p&gt;That is a large, badly-shaped attack surface. Credential reuse means the blast radius of one phished password is not one application but every application that shares it. Offboarding was worse: with sixty independent credential stores, revoking a departed employee's access was a per-app, hours-long chore, which means at any given moment there were almost certainly valid credentials belonging to people who should have had none. MFA coverage was patchy and per-app, so the strength of authentication varied by whichever team had last touched a login page. This is &lt;strong&gt;keycloak identity federation&lt;/strong&gt; territory: the fix is not more helpdesk agents, it is collapsing sixty trust boundaries into one you can actually reason about. Keycloak's &lt;a href="https://www.keycloak.org/docs/latest/server_admin/index.html" rel="noopener noreferrer"&gt;Server Administration Guide&lt;/a&gt; is the reference for the mechanics; the risk framing and sequencing we had to work out ourselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat model
&lt;/h2&gt;

&lt;p&gt;Naming what we were defending against is what justified the rollout order.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credential-reuse blast radius.&lt;/strong&gt; A single reused password turns one compromise into many. As long as each app authenticates independently, there is no way to bound how far a stolen credential reaches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orphaned access after offboarding.&lt;/strong&gt; Sixty credential stores mean revocation is slow and error-prone. Every store you forget is a standing account an attacker or a disgruntled leaver can use. The control objective is single-point revocation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inconsistent authentication strength.&lt;/strong&gt; Without a central policy, MFA and lockout behavior differ per app. The weakest login page sets the real security posture, because that is where an attacker will aim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Big-bang migration risk.&lt;/strong&gt; Federating sixty systems simultaneously with no staging is itself a threat — to availability. The recurring failure story in this space is exactly that: a simultaneous cutover with no fallback that earns a multi-day outage and an emergency rollback. Availability is a security property, so the rollout had to be defensible at every step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design decision that follows: put a broker in the middle that becomes the single authority for authentication, so credential reuse loses its reach, revocation becomes one action, and MFA becomes a policy rather than sixty separate implementations — and get there in phases so we never bet the whole estate on one cutover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controls we added
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Control 1 — a broker so no application stores a password again
&lt;/h3&gt;

&lt;p&gt;Federation does not share credentials — that misconception is itself dangerous, because engineers who believe it will build the wrong thing. Federation shares cryptographically signed &lt;em&gt;assertions&lt;/em&gt; that someone was authenticated. Keycloak sits between the identity provider (our Active Directory, or Google — the systems that vouch for identity) and the service providers (the sixty downstream apps), acting as a broker: what the docs call &lt;a href="https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/26.0/html/server_administration_guide/identity_broker" rel="noopener noreferrer"&gt;integrating identity providers&lt;/a&gt;. It federates our existing AD as the source of truth over LDAP, speaks OIDC and SAML to every app, and means no downstream application holds a credential.&lt;/p&gt;

&lt;p&gt;The security payoff is single-point revocation: disable an account once at the identity provider and access to all sixty apps evaporates. That property — not the convenience — was the security team's reason to approve the project. For the sequencing and the console mechanics I kept &lt;a href="https://dorokhovich.com/blog/keycloak-identity-federation?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=keycloak-identity-federation" rel="noopener noreferrer"&gt;a thorough third-party walkthrough of a phased federation rollout →&lt;/a&gt; open alongside the official docs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 2 — a phased rollout that always has a fallback
&lt;/h3&gt;

&lt;p&gt;We ran it over 90 days, and the phasing was a risk control, not a project-management nicety.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Days 1-30, foundation.&lt;/strong&gt; Stand up dev, staging, and a production cluster. Connect the primary AD as LDAP user federation so disabled accounts and group changes propagate automatically. Then pick three deliberately low-stakes pilot apps — an internal wiki, a dashboard, a staging tool — nothing that pages anyone at 2am if it breaks. For external IdPs, the setting that trips everyone is &lt;strong&gt;First Login Flow → first broker login&lt;/strong&gt;, which determines whether a first-seen federated user links to an existing account or auto-provisions; a wrong choice here is an account-takeover-shaped hazard, so we set it deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 31-60, expansion.&lt;/strong&gt; Add Google as an external IdP for contractors, turn on MFA policies org-wide, integrate the next dozen apps. Monitoring became non-negotiable here: alerting on failed authentications, token-renewal failures, and session timeouts, because incidents live at those boundaries, not the happy path. Delayed detection is reduced detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 61-90, production.&lt;/strong&gt; Roll out to the rest, harden policy, and — critically — stand up and &lt;em&gt;test&lt;/em&gt; backup and disaster recovery for the cluster before decommissioning the old SSO product. A broker with no tested DR is a single point of total authentication failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Control 3 — client configuration as an enforced allow-list
&lt;/h3&gt;

&lt;p&gt;For OIDC apps, each got a client (the &lt;a href="https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/24.0/html/server_administration_guide/assembly-managing-clients_server_administration_guide" rel="noopener noreferrer"&gt;OIDC and SAML client management guide&lt;/a&gt; is the canonical field reference). Two settings carry security weight: &lt;strong&gt;Valid Redirect URIs&lt;/strong&gt; configured exactly — protocol and trailing slash matching character for character, no wildcards, because a loose redirect URI is an open-redirect and token-exfiltration vector; and &lt;strong&gt;Authorization Code flow everywhere with PKCE required&lt;/strong&gt; for public clients. PKCE (&lt;a href="https://oauth.net/2/pkce/" rel="noopener noreferrer"&gt;RFC 7636&lt;/a&gt;) exists specifically to stop authorization-code interception on clients that cannot hold a secret, so we banned the Implicit flow outright.&lt;/p&gt;

&lt;p&gt;For SAML apps the work is metadata exchange plus attribute mapping via per-client protocol mappers. Attribute mapping is where SAML silently goes wrong: if the app expects &lt;code&gt;email&lt;/code&gt; and Keycloak sends &lt;code&gt;emailAddress&lt;/code&gt;, login "succeeds" but the user is provisioned as a stranger — which, depending on the app, can be an authorization failure that grants the wrong access. We standardized on the HTTP-POST binding and reviewed every mapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the controls, not just shipping them
&lt;/h2&gt;

&lt;p&gt;Two verification habits earned their keep. First, &lt;strong&gt;clock synchronization&lt;/strong&gt;: our second SAML integration passed staging and failed intermittently in production with signature errors. SAML assertions carry &lt;code&gt;NotBefore&lt;/code&gt; / &lt;code&gt;NotOnOrAfter&lt;/code&gt; conditions, so IdP and SP clocks must agree within a tolerance — &lt;a href="https://documentation.cloud-iam.com/resources/saml-clock-skew.html" rel="noopener noreferrer"&gt;as the clock-skew writeup explains&lt;/a&gt;, a few seconds of drift rejects a valid assertion, and &lt;code&gt;allowedClockSkew&lt;/code&gt; only widens the window rather than fixing the cause. One node had drifted; we enforced NTP across every host in the auth path. Check clocks before certs.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;negative testing of the controls themselves&lt;/strong&gt;. We tested redirect URIs with deliberately invalid values, not just the happy path, and caught a client that would have accepted a wildcard redirect — a textbook open-redirect and token-leak risk — precisely because the test matrix included a URL we expected Keycloak to reject. A control you never test against a hostile input is security theater; a five-minute negative test replaced a future incident review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Residual risk / what we're still watching
&lt;/h2&gt;

&lt;p&gt;Collapsing sixty trust boundaries into one removes a whole class of reuse and orphaned-access risk, but it concentrates risk rather than deleting it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The broker is now a high-value single point.&lt;/strong&gt; A Keycloak compromise, a signing-key leak, or a cluster outage is an authentication event for the entire estate. That trade is only correct if the concentrated point is hardened, patched, monitored, and backed by tested DR — which is why DR testing gated the decommission of the old system, and why we track Keycloak advisories directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clock and NTP are now a security dependency.&lt;/strong&gt; Time drift on any host in the auth path degrades SAML validation. NTP health is monitored as a security signal, not just an ops metric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SAML attribute-mapping drift.&lt;/strong&gt; A future mapper change that misroutes &lt;code&gt;email&lt;/code&gt; or group claims can silently grant wrong access. Mappers live in reviewed configuration, and we watch for provisioning that produces unexpected identities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-broker-login and redirect-URI loosening.&lt;/strong&gt; Both are one careless edit away from reintroducing account-takeover or open-redirect exposure. They stay in reviewed config, not ad-hoc changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partner federation expands the trust perimeter.&lt;/strong&gt; We are extending federation to a couple of partner organizations so their staff reach shared portals via IdP-to-IdP trust — which means trusting another organization's authentication. Each new trusted IdP is new attack surface and gets onboarded with scrutiny over what claims we accept and how far that trust reaches, not rubber-stamped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The net effect is that a stolen credential no longer roams sixty systems, offboarding is a single revocation, and MFA is a policy rather than sixty implementations — provided we keep treating the broker as the critical asset it has now become.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.keycloak.org/docs/latest/server_admin/index.html" rel="noopener noreferrer"&gt;Keycloak Server Administration Guide — LDAP/AD user federation, identity brokering, first login flow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/26.0/html/server_administration_guide/identity_broker" rel="noopener noreferrer"&gt;Keycloak — Integrating identity providers (Server Admin Guide, Ch. 9)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/24.0/html/server_administration_guide/assembly-managing-clients_server_administration_guide" rel="noopener noreferrer"&gt;Keycloak — Managing OpenID Connect and SAML Clients, incl. protocol mappers (Ch. 12)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://oauth.net/2/pkce/" rel="noopener noreferrer"&gt;PKCE for OAuth 2.0 — why public clients need it (oauth.net / RFC 7636)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://documentation.cloud-iam.com/resources/saml-clock-skew.html" rel="noopener noreferrer"&gt;SAML Clock Skew in Keycloak — allowedClockSkew, NotBefore/NotOnOrAfter, and NTP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/keycloak-identity-federation?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=keycloak-identity-federation" rel="noopener noreferrer"&gt;A phase-by-phase writeup of a 90-day federation rollout&lt;/a&gt; — a useful third-party account of the IdP/SP model and the config details that live outside the primary docs.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>cybersecurity</category>
      <category>security</category>
    </item>
  </channel>
</rss>
