<?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>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>
    <item>
      <title>Green Tests Aren't a Safe Supply Chain: A GitHub Actions Security Audit Gate That Blocks Vulnerable Deploys</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Thu, 06 Aug 2026 09:29:17 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/green-tests-arent-a-safe-supply-chain-a-github-actions-security-audit-gate-that-blocks-vulnerable-4jjc</link>
      <guid>https://dev.to/gaberialsofie/green-tests-arent-a-safe-supply-chain-a-github-actions-security-audit-gate-that-blocks-vulnerable-4jjc</guid>
      <description>&lt;h2&gt;
  
  
  The exposure
&lt;/h2&gt;

&lt;p&gt;Consider the timeline. Friday: all checks pass, PR merged, deploy succeeds, everyone goes home. The following Thursday a critical advisory drops for one of the gems we were running in production, with exploits already circulating — and the way we learned our own app was vulnerable was a Hacker News thread, not our pipeline.&lt;/p&gt;

&lt;p&gt;That is a detection gap, and it is worth naming precisely because CI actively disguises it. &lt;strong&gt;Green tests prove your code behaves; they say nothing about whether your dependencies are safe.&lt;/strong&gt; A pipeline that treats "tests passed" as "safe to ship" is conflating two very different claims, and the space between them is exactly where a known-vulnerable dependency reaches production unopposed. A &lt;strong&gt;github actions security audit gate&lt;/strong&gt; closes that space by making a red audit block the deploy. The design principle we anchored on is the one GitHub itself pushes in its &lt;a href="https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions" rel="noopener noreferrer"&gt;security hardening guide for Actions&lt;/a&gt;: make the safe path the default path, enforced by the pipeline rather than remembered by a human. For the workflow shape and the first-month rollout I also worked from &lt;a href="https://dorokhovich.com/blog/github-actions-security-audit?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=github-actions-security-audit" rel="noopener noreferrer"&gt;a thorough third-party writeup of this audit-gate pattern →&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Known-vulnerable dependency reaching production.&lt;/strong&gt; A gem with a published CVE flowing through a green build into prod is the primary exposure — and because the test suite cannot see it, nothing in the default pipeline stops it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post-merge advisory window.&lt;/strong&gt; New advisories drop after code merges. A dependency that was clean at merge becomes vulnerable in place while sitting in production, and a merge-time-only check never revisits it. This is a mean-time-to-detect problem: ours was roughly seven days, measured from disclosure to a human noticing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;License / legal exposure.&lt;/strong&gt; A copyleft-licensed transitive dependency slipping into a commercial codebase is a distinct risk that neither tests nor CVE scanners address, and it surfaces at the worst time — during due diligence, not development.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gate itself as supply-chain surface.&lt;/strong&gt; Adding third-party Actions to enforce security introduces its own risk: an unpinned marketplace action is mutable and can be re-pointed at hostile code. The control must not become the vulnerability.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Control 1 — a red audit blocks the deploy
&lt;/h3&gt;

&lt;p&gt;The core control is one line. An &lt;code&gt;audit&lt;/code&gt; job runs the scanners; the &lt;code&gt;deploy&lt;/code&gt; job declares &lt;code&gt;needs: [audit]&lt;/code&gt;, so it will not start unless the audit is green. No audit pass, no deploy — the safe path stops being something a human remembers and becomes what the pipeline enforces.&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;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="nv"&gt;.ruby-version&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 license_finder&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="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;license_finder --quiet&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;--recursive&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--skip-git&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;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;audit&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# deploy won't start until audit is green&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="c1"&gt;# ... deploy steps&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Control 2 — three scanners for defense in depth
&lt;/h3&gt;

&lt;p&gt;We chose three complementary tools rather than one, because they cover different blind spots and layering them is cheap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/rubysec/bundler-audit" rel="noopener noreferrer"&gt;&lt;code&gt;bundler-audit&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — RubySec's patch-level verification, scanning &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; for known CVEs, and flagging insecure &lt;code&gt;http://&lt;/code&gt;/&lt;code&gt;git://&lt;/code&gt; gem sources. Deepest on Ruby advisory data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/pivotal/LicenseFinder" rel="noopener noreferrer"&gt;&lt;code&gt;license_finder&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — reports dependencies outside your approved-license policy, covering the legal risk the CVE scanners ignore entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google's &lt;a href="https://github.com/google/osv-scanner" rel="noopener noreferrer"&gt;&lt;code&gt;OSV-Scanner&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;, backed by &lt;a href="https://osv.dev/" rel="noopener noreferrer"&gt;OSV.dev&lt;/a&gt; across many ecosystems — broadest coverage, so it catches vulnerabilities in polyglot repos, not just Ruby.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Overlapping controls is deliberate: no single advisory source is complete, so two independent scanners plus a license check reduce the residual chance that a known issue slips past all of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 3 — trigger strategy that closes the post-merge window
&lt;/h3&gt;

&lt;p&gt;The triggers are a control in their own right:&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;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt; &lt;span class="pi"&gt;]&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="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&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;*.gemspec'&lt;/span&gt;&lt;span class="pi"&gt;]&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;8&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;MON'&lt;/span&gt;   &lt;span class="c1"&gt;# weekly re-audit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pull_request&lt;/code&gt; scoped to dependency files&lt;/strong&gt; so the gate only fires when dependencies actually change — which keeps it off most PRs and prevents the resentment that gets security gates disabled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The weekly &lt;code&gt;schedule&lt;/code&gt; cron&lt;/strong&gt; is the one that addresses the post-merge window directly: it re-audits already-deployed code against advisories that did not exist at merge, moving mean-time-to-detect from about a week to same-day or next-scheduled-run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;push&lt;/code&gt; to &lt;code&gt;main&lt;/code&gt;&lt;/strong&gt; as the last check before production.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Control 4 — hardening the gate itself
&lt;/h3&gt;

&lt;p&gt;Because the gate depends on third-party Actions, we treated that dependency as supply-chain surface. GitHub's hardening guide is explicit that pinning to a full-length commit SHA "is currently the only way to use an action as an immutable release," so third-party actions are SHA-pinned rather than tag-referenced. When the tagged OSV action broke on a stale reference, we installed the scanner as a binary instead — vendor-neutral and not subject to a mutable tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/google/osv-scanner/main/scripts/install.sh | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; /usr/local/bin
osv-scanner &lt;span class="nt"&gt;--recursive&lt;/span&gt; &lt;span class="nt"&gt;--skip-git&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also adopted a few Bundler settings to reduce source-substitution surface (&lt;code&gt;bundle config set disable_multisource true&lt;/code&gt;, &lt;code&gt;cache_all true&lt;/code&gt;, &lt;code&gt;clean 'true'&lt;/code&gt;), layered Trivy alongside OSV for further depth, and put a quarterly dependency-hygiene day on the calendar.&lt;/p&gt;

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

&lt;p&gt;A gate is only real if it fails when it should and the team keeps it on. The first run failed — we had two existing advisories on merge — and that failure was the point: triaging them was the whole value, not something to route around by disabling the step. Two design choices kept the gate trusted rather than resented. Because the audit runs on &lt;code&gt;pull_request&lt;/code&gt; only when &lt;code&gt;Gemfile*&lt;/code&gt; changes, most PRs never see it. And because &lt;code&gt;bundle audit&lt;/code&gt; prints the advisory URL and the fixed version, "unblock" almost always meant a one-line version bump rather than a research project. After the team saw the red build point at a real, patchable issue every time — no false-positive noise — trust went up, not down. By week four a red audit was simply "bump the gem and move on." A couple of operational notes that matter: &lt;code&gt;bundle audit&lt;/code&gt; needs &lt;code&gt;--update&lt;/code&gt; or it scans a stale advisory DB and gives false comfort, and &lt;code&gt;license_finder&lt;/code&gt; will surprise people (ours flagged a transitive GPL dependency nobody had noticed), so set your allowed-license policy up front.&lt;/p&gt;

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

&lt;p&gt;The gate reliably keeps known-vulnerable dependencies out of production, but it is not a complete supply-chain defense, and being honest about the edges is the point.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scanners find &lt;em&gt;known&lt;/em&gt; vulnerabilities only.&lt;/strong&gt; A zero-day or a brand-new typosquat with no advisory yet passes clean. The gate lowers mean-time-to-detect for disclosed issues; it does not detect the undisclosed. Human review of new dependencies still carries that load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate-bypass is a cultural failure mode.&lt;/strong&gt; The most likely way this control dies is a developer commenting out the audit step to unblock a feature. We defend it by keeping it fast, legible, and low-false-positive so bypassing feels unnecessary — but a determined bypass is always possible, so we also protect the workflow file in review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party action drift.&lt;/strong&gt; SHA-pinning freezes an action, but a frozen action also stops receiving fixes; we periodically review pinned SHAs so "immutable" does not become "stale and unpatched."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coverage gaps beyond gems.&lt;/strong&gt; The gate audits dependencies, not our container base images or the runner environment. We are extending the same pattern to image scanning next, and rolling the gate out as a reusable workflow so every repo inherits it rather than reimplementing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The advisory databases are themselves a dependency.&lt;/strong&gt; Our detection is only as current as OSV.dev and the ruby-advisory-db; a gap or delay in their data is a gap in ours. Running two independent sources mitigates but does not eliminate that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The measurable outcome that mattered: mean-time-to-detect a known-vulnerable dependency dropped from about a week to same-day, and twice the Monday cron flagged a fresh advisory on already-deployed code before any customer or news cycle did. What we keep watching is the gap the gate cannot close — the undisclosed vulnerability and the human tempted to route around a red build.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&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; — RubySec's patch-level verification and the advisory data behind it&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/pivotal/LicenseFinder" rel="noopener noreferrer"&gt;LicenseFinder&lt;/a&gt; — Pivotal's dependency-license auditor&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/google/osv-scanner" rel="noopener noreferrer"&gt;OSV-Scanner&lt;/a&gt; and &lt;a href="https://osv.dev/" rel="noopener noreferrer"&gt;OSV.dev&lt;/a&gt; — Google's multi-ecosystem scanner and the open vulnerability database it queries&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions" rel="noopener noreferrer"&gt;Security hardening for GitHub Actions&lt;/a&gt; — GitHub's official guide (SHA-pinning, minimal &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; permissions)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/actions/reference/events-that-trigger-workflows" rel="noopener noreferrer"&gt;Events that trigger workflows&lt;/a&gt; — GitHub docs for &lt;code&gt;workflow_run&lt;/code&gt;, &lt;code&gt;schedule&lt;/code&gt;, &lt;code&gt;pull_request&lt;/code&gt; filters&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/github-actions-security-audit?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=github-actions-security-audit" rel="noopener noreferrer"&gt;A writeup of this audit-gate rollout&lt;/a&gt; — a useful third-party account of both workflow variants, the first-month adoption story, and the Bundler hardening config.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>github</category>
      <category>security</category>
    </item>
    <item>
      <title>Adopting Keycloak Without a Bulk-Credential Migration: A Keycloak Custom Provider (SPI) Threat Model</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Wed, 05 Aug 2026 10:05:12 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/adopting-keycloak-without-a-bulk-credential-migration-a-keycloak-custom-provider-spi-threat-model-4lla</link>
      <guid>https://dev.to/gaberialsofie/adopting-keycloak-without-a-bulk-credential-migration-a-keycloak-custom-provider-spi-threat-model-4lla</guid>
      <description>&lt;h2&gt;
  
  
  The exposure
&lt;/h2&gt;

&lt;p&gt;Our Keycloak rollout stalled on two problems that were, underneath, the same kind of problem: both were about where trust and evidence actually live. We had 200,000 users in a legacy PostgreSQL table with a bespoke password-hashing scheme, and compliance required that every authentication event — successful logins, failed attempts, password changes — reach our SIEM in near real time.&lt;/p&gt;

&lt;p&gt;The naive fix for the first problem is a big-bang migration: export the users, re-hash or force-reset every password, cut over in a maintenance window. That migration is itself the exposure. Bulk-handling 200k credential records is a concentrated moment of risk — a dump of password material in flight, a mapping bug that silently drops or duplicates accounts, and a forced-reset flow that is a textbook phishing pretext ("we've upgraded our login, please reset your password here"). The second problem is a detection gap: Keycloak's default event storage kept auth events, but not where our monitoring could see them within seconds, which means the window between a credential-stuffing burst and anyone noticing was measured in whatever our slowest batch job took.&lt;/p&gt;

&lt;p&gt;So the real question was not "how do we migrate?" but "how do we adopt Keycloak without ever creating a bulk-credential event, and without accepting a blind spot in login telemetry?" Keycloak, the open-source identity and access management server, answers that through its &lt;a href="https://www.keycloak.org/docs/latest/server_development/index.html#_providers" rel="noopener noreferrer"&gt;Service Provider Interfaces (SPI)&lt;/a&gt; — the extension seam that lets you change where users are authenticated and where events are sent without forking the server.&lt;/p&gt;

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

&lt;p&gt;Being precise about what we were defending against is what kept the extension small and the review tractable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bulk credential exposure during migration.&lt;/strong&gt; Any process that reads, transforms, and re-writes 200k password hashes in one operation is a high-value target and a single point of failure. The control objective is to never have that operation exist at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Account-lockout and phishing risk from forced resets.&lt;/strong&gt; A mass password-reset email trains users to click reset links, which is exactly the behavior an attacker wants to exploit. It also risks locking out legitimate customers on a mapping error, which is an availability incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detection gap on authentication.&lt;/strong&gt; If failed logins and password changes are not streamed to the SIEM promptly, credential-stuffing, brute force, and account-takeover attempts go unobserved until after the fact. Delayed telemetry is reduced detection capability, which is a security control, not an operational nicety.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extension-introduced attack surface.&lt;/strong&gt; Writing custom Java that runs inside the identity server is itself a risk: a provider on the request path can add latency, leak connections, or — worst case — mishandle a credential. The extension must be scoped so its blast radius is understood.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The governing decision: replace a one-time, high-magnitude credential-handling event with a gradual, per-login re-hash where the legacy store stays authoritative until each user proves themselves, and close the detection gap by streaming events at the source. Both are SPI providers, and both are small.&lt;/p&gt;

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

&lt;p&gt;Keycloak ships LDAP and Active Directory federation, but our legacy store was neither, and its default event handling did not stream where we needed. That is precisely where you stop configuring Keycloak and start extending it — deliberately, and with the smallest surface that satisfies the requirement. I cross-referenced the official developer guide against &lt;a href="https://dorokhovich.com/blog/extending-keycloak-custom-providers?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=extending-keycloak-custom-providers" rel="noopener noreferrer"&gt;a thorough third-party walkthrough of building SPI providers →&lt;/a&gt;, which works each interface end to end.&lt;/p&gt;

&lt;p&gt;There are four SPI provider types — authentication, user storage, event listener, and policy. We needed two, and deliberately no more than two.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 1 — federate the legacy users so no bulk migration ever happens
&lt;/h3&gt;

&lt;p&gt;We implemented &lt;code&gt;org.keycloak.storage.UserStorageProvider&lt;/code&gt;, the same &lt;a href="https://www.keycloak.org/docs/latest/server_development/index.html#_user-storage-spi" rel="noopener noreferrer"&gt;User Storage SPI&lt;/a&gt; that Keycloak's own built-in LDAP and AD federation is written against. The two methods that carry the weight are &lt;code&gt;getUserById(String id, RealmModel realm)&lt;/code&gt; and &lt;code&gt;getUserByUsername(String username, RealmModel realm)&lt;/code&gt;; Keycloak calls them to resolve a user, and our implementation queried the legacy table and mapped rows to Keycloak user models on demand.&lt;/p&gt;

&lt;p&gt;The security-relevant property is that &lt;strong&gt;users authenticate against the old store without ever being migrated in bulk.&lt;/strong&gt; We validated each login against the legacy hash and, only on a successful authentication, transparently re-hashed that one credential into Keycloak's format. The re-hash happens exactly when the user has just proven they hold the password — there is no batch, no dump, no reset email, and the legacy store remains authoritative for anyone who has not yet logged in. That converts one large, concentrated credential-handling risk into a stream of individually trivial ones. Baeldung's &lt;a href="https://www.baeldung.com/java-keycloak-custom-user-providers" rel="noopener noreferrer"&gt;custom user providers walkthrough&lt;/a&gt; is a solid worked reference for the interface.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 2 — stream auth events to the SIEM at the source
&lt;/h3&gt;

&lt;p&gt;For the detection gap we implemented &lt;code&gt;org.keycloak.events.EventListenerProvider&lt;/code&gt;, registered through Keycloak's &lt;a href="https://www.keycloak.org/docs/latest/server_development/index.html#_events" rel="noopener noreferrer"&gt;Events SPI&lt;/a&gt;. The method that matters is &lt;code&gt;onEvent(Event event)&lt;/code&gt;, invoked on every login, failed attempt, password change, and account update. Our implementation serialized each event and pushed it to the SIEM ingest endpoint. The monitoring team got the authentication telemetry they needed to detect credential-stuffing and takeover attempts promptly, in a few hundred lines rather than a platform change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 3 — build and package the extension so it cannot silently misload
&lt;/h3&gt;

&lt;p&gt;Both providers are Java built with Maven. The single most important build decision is scope: both Keycloak artifacts (&lt;code&gt;keycloak-services&lt;/code&gt;, &lt;code&gt;keycloak-core&lt;/code&gt;) use &lt;code&gt;provided&lt;/code&gt; scope, because Keycloak supplies them at runtime. You compile against them and do not bundle them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.keycloak&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;keycloak-services&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;YOUR_KEYCLOAK_VERSION&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;scope&amp;gt;&lt;/span&gt;provided&lt;span class="nt"&gt;&amp;lt;/scope&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every provider builds on &lt;code&gt;org.keycloak.provider.Provider&lt;/code&gt; (whose lifecycle includes &lt;code&gt;close()&lt;/code&gt; for releasing resources) plus a &lt;code&gt;ProviderFactory&lt;/code&gt; that reads configuration via &lt;code&gt;init(Config.Scope config)&lt;/code&gt;. That factory-and-config pattern is a control in its own right: it let the same JAR point at the test database in CI and the production database in prod, so the only difference between environments was reviewed configuration, not code.&lt;/p&gt;

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

&lt;p&gt;An extension that runs inside the identity server is not trustworthy until it is demonstrated to fail safely. Our loop: &lt;code&gt;mvn clean install&lt;/code&gt;, drop the JAR into Keycloak's &lt;code&gt;providers/&lt;/code&gt; directory, run a build, and confirm the factory appears in the startup log — if it is not listed, the &lt;code&gt;META-INF/services&lt;/code&gt; registration is wrong and Keycloak silently ignores the JAR, which is a fail-silent condition worth catching in CI rather than in production.&lt;/p&gt;

&lt;p&gt;For the user-storage provider we wrote an integration test that spun Keycloak up in a container against a seeded legacy database, then asserted that a known legacy user could authenticate and that their credential was transparently re-hashed on first success. A mapping bug caught here is a failing test; the same bug found in production is a customer who cannot log in. We never pointed a new provider at the production realm until it had authenticated a test user and emitted a test event end to end against a throwaway realm.&lt;/p&gt;

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

&lt;p&gt;Extending Keycloak solved the migration and detection problems, but it moved risk rather than eliminating it, and naming where it went is the point.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The event listener runs in the request path.&lt;/strong&gt; A synchronous, slow SIEM push adds latency to every login and, in the worst case, could be a denial-of-service lever against authentication itself. We made the emit asynchronous behind a bounded queue so a slow or unreachable SIEM never blocks auth — but a bounded queue also means events can be dropped under sustained backpressure, so we monitor queue saturation as a signal that we are losing telemetry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version lock-step is now a standing obligation.&lt;/strong&gt; The internal SPI is not guaranteed stable across Keycloak majors, so the provider must be rebuilt and re-tested against the exact version it runs on. A forgotten rebuild after an upgrade is a provider that compiles against the wrong internals and fails in subtle ways — we pin the version and rebuild on every upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection hygiene under load.&lt;/strong&gt; A storage provider that does not release database connections in &lt;code&gt;close()&lt;/code&gt; leaks them, which is an availability failure mode. We assert cleanup in the lifecycle method and watch connection-pool metrics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The legacy store stays authoritative longer than we would like.&lt;/strong&gt; Gradual re-hash is safer than a big bang, but it means the old database — with its bespoke hashing — remains in the trust path until the long tail of inactive users either logs in or is aged out. We are tracking the migration curve and will set a hard cutoff date after which un-migrated accounts are disabled rather than left as indefinite legacy attack surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any future authentication or policy provider is new surface.&lt;/strong&gt; We are evaluating a policy provider for location- and device-aware access on sensitive resources, and a step-up MFA authentication provider. Each runs inside the identity server, so each gets the same throwaway-realm-first, integration-tested, version-pinned treatment — no rubber-stamping code that sits on the credential path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The net effect is that we adopted Keycloak without ever creating a bulk-credential event and without accepting an authentication blind spot, in roughly 300 lines of Java. The lesson we took from it is narrow and deliberate: the SPI seam is powerful precisely because it lets you avoid the riskier alternative, but every line you run inside the identity server is code on your most sensitive path, and it earns the same scrutiny as the server itself.&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_development/index.html#_providers" rel="noopener noreferrer"&gt;Keycloak Server Developer Guide — Service Provider Interfaces (SPI)&lt;/a&gt; — the provider/factory model, &lt;code&gt;META-INF/services&lt;/code&gt; registration, and packaging.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.keycloak.org/docs/latest/server_development/index.html#_user-storage-spi" rel="noopener noreferrer"&gt;Keycloak Server Developer Guide — User Storage SPI&lt;/a&gt; — the interface behind federating an external user store without migration.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.keycloak.org/docs/latest/server_development/index.html#_events" rel="noopener noreferrer"&gt;Keycloak Server Developer Guide — Events&lt;/a&gt; — the Event Listener SPI used to stream auth events.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.baeldung.com/java-keycloak-custom-user-providers" rel="noopener noreferrer"&gt;Baeldung — Using Custom User Providers with Keycloak&lt;/a&gt; — a hands-on Java worked example of a UserStorageProvider.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/extending-keycloak-custom-providers?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=extending-keycloak-custom-providers" rel="noopener noreferrer"&gt;A hands-on custom-provider writeup&lt;/a&gt; — a useful third-party field-notes version of the Maven setup and both providers to keep open while building.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
  </channel>
</rss>
