<?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>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>
    <item>
      <title>Shrinking the Blast Radius of Our Identity Plane: Deploy Keycloak on AWS ECS Fargate with Terraform</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Tue, 04 Aug 2026 08:24:43 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/shrinking-the-blast-radius-of-our-identity-plane-deploy-keycloak-on-aws-ecs-fargate-with-terraform-26n</link>
      <guid>https://dev.to/gaberialsofie/shrinking-the-blast-radius-of-our-identity-plane-deploy-keycloak-on-aws-ecs-fargate-with-terraform-26n</guid>
      <description>&lt;h2&gt;
  
  
  The exposure
&lt;/h2&gt;

&lt;p&gt;The riskiest asset in our environment was never a customer-facing app. It was the component every one of those apps delegated trust to. Our Keycloak deployment issued the OIDC tokens and enforced the login flow for every internal service, and it ran on a single, hand-configured EC2 instance, &lt;code&gt;auth-prod-1&lt;/code&gt;, with PostgreSQL installed on the same host. No infrastructure-as-code, no reviewed change history, no standby. Roughly two years of realm, client, user, and role-mapping data lived on one EBS volume for which nobody had a tested restore path.&lt;/p&gt;

&lt;p&gt;Framed in blast-radius terms, that box was the highest-value single point of failure we owned. Its loss did not degrade one service; it revoked the ability to authenticate to all of them. And because the identity store was co-located with the application, a host compromise was not merely a downtime event, it was a full credential-store disclosure: password hashes, client secrets, and active session state, all reachable from the same shell. The state had been built by clicking and SSH-ing over two years, which meant the exposure was also un-auditable: we could not enumerate, with confidence, what was actually reachable or how it was configured. That uncertainty is itself a security finding.&lt;/p&gt;

&lt;p&gt;The goal of the rebuild was not "move to containers." It was to shrink the blast radius, make the configuration auditable, and put defensible boundaries between the parts of the system that currently fail together.&lt;/p&gt;

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

&lt;p&gt;Before touching Terraform I mapped out what we were actually defending, because "make it highly available" is not a threat model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assets, in priority order.&lt;/strong&gt; The integrity of the token-issuance path (an attacker who can mint or alter tokens owns every downstream service); the confidentiality of the realm database (password hashes, client secrets, session data); and the availability of the identity provider itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adversaries and failure modes we scoped in&lt;/strong&gt;, roughly ordered by the residual risk they carried on the old design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Host compromise escalating to credential-store access.&lt;/strong&gt; Application and database shared a trust boundary, so any RCE in Keycloak, any dependency CVE, any stray SSH key was one hop from the full user store. The blast radius of an app compromise equaled the value of the data on the same box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loss-of-availability events.&lt;/strong&gt; A single instance, a single AZ, a single disk: availability of the entire authentication plane depended on the least reliable component in the stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un-auditable configuration drift.&lt;/strong&gt; Hand-applied changes with no review gate mean no way to reason about the current attack surface, or to tell an unauthorized change from an authorized one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network-adjacent lateral movement.&lt;/strong&gt; A flat network path to the database, secured by host-level assumptions rather than an explicit deny-by-default boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application-layer identity flaws in Keycloak itself.&lt;/strong&gt; Open-redirect and redirect-URI validation classes (for example the path-normalization bypass tracked as CVE-2024-1132, and the SSRF-via-request_uri class of CVE-2020-10770) are exploitable regardless of where Keycloak runs, and they leak or misdirect tokens. Hosting choices do not fix these; a patch cadence and strict hostname/redirect config do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Explicitly out of scope for this pass:&lt;/strong&gt; realm-level policy hardening (MFA enforcement, password policy, token lifetimes) and a formal disaster-recovery game day. Naming them as out of scope is deliberate. They are known residual risk, tracked below, not oversights.&lt;/p&gt;

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

&lt;p&gt;The design applies defense in depth: no single control is load-bearing, and each one narrows the blast radius of the layer above it. We rebuilt the plane as Keycloak on ECS Fargate, Aurora PostgreSQL in private subnets, an Application Load Balancer, and Route 53, defined entirely as reviewed Terraform. A third-party &lt;a href="https://dorokhovich.com/blog/deploying-keycloak-aws-ecs-fargate-terraform?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=deploying-keycloak-aws-ecs-fargate-terraform" rel="noopener noreferrer"&gt;writeup on the full Terraform setup&lt;/a&gt; covers the complete VPC/ALB/Route 53 wiring; below I focus on the controls and why each one earns its place in the threat model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 1 — Remove the host to shrink the compromise surface
&lt;/h3&gt;

&lt;p&gt;Moving Keycloak onto ECS Fargate removes the long-lived, hand-patched host from the picture entirely. There is no SSH surface, no persistent OS to accrue drift, and the container is replaced rather than patched in place. The task definition runs the official image in production mode against the managed database, following &lt;a href="https://www.keycloak.org/server/configuration-production" rel="noopener noreferrer"&gt;Keycloak's own production configuration guidance&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ecs_task_definition"&lt;/span&gt; &lt;span class="s2"&gt;"keycloak"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;family&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"keycloak"&lt;/span&gt;
  &lt;span class="nx"&gt;network_mode&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"awsvpc"&lt;/span&gt;           &lt;span class="c1"&gt;# mandatory on Fargate; gives each task its own ENI&lt;/span&gt;
  &lt;span class="nx"&gt;requires_compatibilities&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"FARGATE"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;cpu&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1024"&lt;/span&gt;
  &lt;span class="nx"&gt;memory&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2048"&lt;/span&gt;
  &lt;span class="nx"&gt;execution_role_arn&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ecs_task_execution_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;  &lt;span class="c1"&gt;# least-privilege pull + secrets read&lt;/span&gt;
  &lt;span class="nx"&gt;task_role_arn&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keycloak_task_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;

  &lt;span class="nx"&gt;container_definitions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"keycloak"&lt;/span&gt;
      &lt;span class="nx"&gt;image&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"quay.io/keycloak/keycloak:24.0.5"&lt;/span&gt;
      &lt;span class="nx"&gt;essential&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="nx"&gt;portMappings&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;containerPort&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
      &lt;span class="nx"&gt;environment&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="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KC_DB"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KC_HOSTNAME"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"auth.example.com"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KC_PROXY_HEADERS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"xforwarded"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nx"&gt;secrets&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="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KC_DB_URL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="nx"&gt;valueFrom&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kc_db_url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KC_DB_USERNAME"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="nx"&gt;valueFrom&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kc_db_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KC_DB_PASSWORD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="nx"&gt;valueFrom&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kc_db_pass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KEYCLOAK_ADMIN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="nx"&gt;valueFrom&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kc_admin_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KEYCLOAK_ADMIN_PASSWORD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;valueFrom&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kc_admin_pass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"--optimized"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions are security-relevant. &lt;code&gt;command = ["start", "--optimized"]&lt;/code&gt; runs Keycloak in production mode against a pre-built configuration rather than &lt;code&gt;start-dev&lt;/code&gt;, disabling the dev-mode conveniences that widen the attack surface. And every credential moves out of &lt;code&gt;environment&lt;/code&gt; into &lt;code&gt;secrets&lt;/code&gt;, sourced from Secrets Manager at runtime, so plaintext passwords never sit in the task definition, in Terraform state as a resolved value, or in the ECS console.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 2 — Isolate the credential store behind a deny-by-default boundary
&lt;/h3&gt;

&lt;p&gt;The single most important boundary in the old design that did not exist: the database is no longer on the application host. It moves to &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/CHAP_AuroraOverview.html" rel="noopener noreferrer"&gt;managed Aurora PostgreSQL&lt;/a&gt; in private subnets, and its ingress is scoped to the Keycloak service's security group rather than a CIDR range. That is least privilege expressed at the network layer: the database accepts 5432 from the identity of the calling service, and nothing else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"database"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"keycloak-database-sg"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;

  &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;description&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PostgreSQL from Keycloak tasks only"&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;security_groups&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keycloak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# SG-to-SG, not a CIDR&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_rds_cluster"&lt;/span&gt; &lt;span class="s2"&gt;"keycloak"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_identifier&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"keycloak-cluster"&lt;/span&gt;
  &lt;span class="nx"&gt;engine&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aurora-postgresql"&lt;/span&gt;
  &lt;span class="nx"&gt;engine_version&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"15.4"&lt;/span&gt;
  &lt;span class="nx"&gt;database_name&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"keycloak"&lt;/span&gt;
  &lt;span class="nx"&gt;master_username&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"keycloak"&lt;/span&gt;
  &lt;span class="nx"&gt;master_password&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;database_password&lt;/span&gt;       &lt;span class="c1"&gt;# bootstrap only; rotated into Secrets Manager&lt;/span&gt;
  &lt;span class="nx"&gt;backup_retention_period&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;
  &lt;span class="nx"&gt;storage_encrypted&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;                        &lt;span class="c1"&gt;# encryption at rest for the user store&lt;/span&gt;
  &lt;span class="nx"&gt;db_subnet_group_name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_db_subnet_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keycloak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_security_group_ids&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;skip_final_snapshot&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;final_snapshot_identifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"keycloak-final"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;storage_encrypted = true&lt;/code&gt; protects the user store at rest, the SG-to-SG rule collapses the reachable network path to exactly one source, and 14-day retention with a real final snapshot gives us a recovery point the old ad-hoc setup never had. The host-compromise-to-credential-store path from the threat model is now a two-boundary problem instead of a one-shell problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 3 — Terminate TLS at the ALB and trust the proxy correctly
&lt;/h3&gt;

&lt;p&gt;The ALB terminates TLS and is the only component with a public interface; the tasks sit in private subnets, unreachable except through it. This matters for correctness as well as exposure: behind a terminating proxy, Keycloak must be told to trust forwarded headers or it builds redirect and issuer URLs from the wrong host, which is both a broken login flow and, misconfigured, an open-redirect risk. Per the &lt;a href="https://www.keycloak.org/server/reverseproxy" rel="noopener noreferrer"&gt;reverse-proxy guide&lt;/a&gt; we set &lt;code&gt;KC_PROXY_HEADERS=xforwarded&lt;/code&gt; (the older &lt;code&gt;KC_PROXY&lt;/code&gt; flag was deprecated in Keycloak 24), and per the &lt;a href="https://www.keycloak.org/server/hostname" rel="noopener noreferrer"&gt;hostname guide&lt;/a&gt; we pin &lt;code&gt;KC_HOSTNAME&lt;/code&gt; to the exact Route 53 domain so issuer and admin URLs cannot be coerced to an attacker-supplied host. Locking the hostname is the direct mitigation for the redirect-validation CVE class from the threat model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;Controls that are not verified are assumptions. Before cutting over we exercised each one against the threat model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network boundary.&lt;/strong&gt; From a host outside the Keycloak security group, a connection to the Aurora endpoint on 5432 must time out; from a Keycloak task it must succeed. We confirmed both, so the SG-to-SG rule is enforcing, not merely declared.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret hygiene.&lt;/strong&gt; We grepped the rendered task definition and the ECS console for plaintext credentials and confirmed only Secrets Manager ARNs appear, and that resolved secret values are absent from Terraform state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy and hostname correctness.&lt;/strong&gt; We validated that the OIDC discovery document, issuer, and every client redirect URI resolve to the canonical Route 53 hostname over TLS, and that requests spoofing a &lt;code&gt;Host&lt;/code&gt; or &lt;code&gt;X-Forwarded-Host&lt;/code&gt; header do not change the issued URLs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration integrity.&lt;/strong&gt; We exported the realms with &lt;code&gt;kc.sh export&lt;/code&gt;, imported into a fresh Aurora database, and checked every client and redirect URI against a checklist. The new stack ran in parallel on a temporary hostname and served real logins before we moved DNS. Because the old host stayed untouched, rollback was a one-line DNS revert, keeping the cutover's own blast radius small.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Rebuilding the plane reduced the exposure; it did not eliminate risk, and it would be dishonest to present it that way. What we are still watching:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Patch cadence is now the primary risk owner for application-layer flaws.&lt;/strong&gt; Fargate solved host patching but not Keycloak's own CVE stream. Redirect-URI and SSRF classes like CVE-2024-1132 and CVE-2020-10770 are exploitable regardless of hosting, so we are wiring image-tag currency and CVE alerting into the pipeline and treating a stale image tag as a finding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bootstrap credentials and rotation.&lt;/strong&gt; The Aurora master password still enters the world as a Terraform variable before it is rotated into Secrets Manager, and rotation is not yet automated. That bootstrap window is real residual risk; automatic rotation is the next control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distributed session state across tasks.&lt;/strong&gt; Multiple Keycloak tasks behind the ALB without a shared cache can drop sessions as requests bounce between instances. Until the Infinispan/JDBC-Ping cache is configured we lean on sticky sessions, which is a workaround, not a design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realm-level policy and a DR game day remain out of scope.&lt;/strong&gt; MFA enforcement, token lifetimes, and a tested full-restore of the encrypted Aurora snapshot are named, tracked work. A backup you have never restored is a hypothesis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin console exposure.&lt;/strong&gt; The admin endpoints currently share the ALB with the login flow. Splitting them onto a restricted path or internal listener is queued to further shrink the surface an external attacker can reach.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core exposure, an entire company's authentication resting on one un-auditable pet server, is gone. What remains is a set of named, bounded risks with owners, which is a defensible position to operate from rather than a fragile one to hope over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.keycloak.org/server/configuration-production" rel="noopener noreferrer"&gt;Configuring Keycloak for production&lt;/a&gt; — production-mode baseline and hardening checklist.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.keycloak.org/server/reverseproxy" rel="noopener noreferrer"&gt;Configuring a reverse proxy (Keycloak)&lt;/a&gt; — proxy header modes and why &lt;code&gt;KC_PROXY_HEADERS&lt;/code&gt; replaced &lt;code&gt;KC_PROXY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.keycloak.org/server/hostname" rel="noopener noreferrer"&gt;Configuring the hostname (Keycloak)&lt;/a&gt; — pinning &lt;code&gt;KC_HOSTNAME&lt;/code&gt; and issuer URLs behind a load balancer.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html" rel="noopener noreferrer"&gt;Amazon ECS task definition parameters for Fargate&lt;/a&gt; — the &lt;code&gt;awsvpc&lt;/code&gt; requirement and per-task ENI behavior.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/CHAP_AuroraOverview.html" rel="noopener noreferrer"&gt;What is Amazon Aurora? (AWS docs)&lt;/a&gt; — the managed, encrypted HA database backing the user store.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/deploying-keycloak-aws-ecs-fargate-terraform?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=deploying-keycloak-aws-ecs-fargate-terraform" rel="noopener noreferrer"&gt;A thorough third-party writeup on the full Terraform setup&lt;/a&gt; — the complete RDS/ALB/VPC/Route 53 Terraform for this architecture.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>security</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Attack Surface of Hand-Rolled Auth: Consolidating Keycloak FastAPI Role Based Access Control Across 40 Endpoints</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:25:00 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/attack-surface-of-hand-rolled-auth-consolidating-keycloak-fastapi-role-based-access-control-across-17j5</link>
      <guid>https://dev.to/gaberialsofie/attack-surface-of-hand-rolled-auth-consolidating-keycloak-fastapi-role-based-access-control-across-17j5</guid>
      <description>&lt;h2&gt;
  
  
  The exposure
&lt;/h2&gt;

&lt;p&gt;Our FastAPI service enforced authorization the way a lot of services quietly do: every route decoded its own JWT, pulled the role claim, and made a local &lt;code&gt;if user.role == 'admin'&lt;/code&gt; decision. Across roughly 40 endpoints and three engineers, that is not one access-control mechanism — it is 40 independent enforcement points, each of which can drift, and each of which is a place where a control can simply be absent.&lt;/p&gt;

&lt;p&gt;The failure mode showed up in review: an admin-only endpoint with no role check at all. It had shipped weeks earlier and passed every test, because the tests asserted the happy path and nobody wrote a test for a control that was never there. The important thing is not the individual mistake. The important thing is the property of the system — &lt;strong&gt;authorization with no single point of enforcement fails open by omission&lt;/strong&gt;, and omission is invisible. "The check is supposed to be there" is a hope, not a control.&lt;/p&gt;

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

&lt;p&gt;Before changing anything, it helps to be precise about what we were actually defending against, because that framing dictates which controls are worth the effort.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Broken access control&lt;/strong&gt; — the top item on the OWASP Top 10 (A01:2021) — is exactly the class of defect we had: an authenticated user reaching an endpoint their role should not permit. Distributed, hand-written checks maximize the surface for this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privilege escalation via a missing or wrong check.&lt;/strong&gt; A &lt;code&gt;guest&lt;/code&gt; or &lt;code&gt;user&lt;/code&gt; token reaching an &lt;code&gt;admin&lt;/code&gt; route because one of 40 checks was omitted, misspelled (&lt;code&gt;"admin"&lt;/code&gt; vs &lt;code&gt;"administrator"&lt;/code&gt;), or copied from the wrong template.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inconsistent token validation.&lt;/strong&gt; Every service hand-parsing JWTs means signature verification, &lt;code&gt;aud&lt;/code&gt;, &lt;code&gt;iss&lt;/code&gt;, and expiry handling can differ per route. Any route that validates loosely widens the blast radius of a leaked or forged token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No audit trail.&lt;/strong&gt; When someone asks "who can reach this?", the answer being a &lt;code&gt;grep&lt;/code&gt; across the codebase is itself a finding — you cannot attest to a control you have to reconstruct by reading source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The residual-risk question that drove the design: if one endpoint is written wrong, how much does that cost us? With per-route checks, one mistake is one breach. The goal was to make the default path safe so that the cost of a single mistake drops toward zero.&lt;/p&gt;

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

&lt;p&gt;The governing principle is centralizing the authorization decision into a single, hardened authority and making the safe integration the easy one — defense in depth backed by least privilege, rather than 40 bespoke gates. Keycloak, the open-source identity and access management server, becomes that authority: it owns identity, roles, and token issuance, and every service is reduced to &lt;em&gt;validating&lt;/em&gt; what Keycloak signed. It speaks &lt;a href="https://openid.net/specs/openid-connect-core-1_0.html" rel="noopener noreferrer"&gt;OpenID Connect&lt;/a&gt;, the identity layer over OAuth 2.0, so the integration is standard rather than bespoke, and the &lt;a href="https://www.keycloak.org/securing-apps/oidc-layers" rel="noopener noreferrer"&gt;Keycloak securing-applications guide&lt;/a&gt; is explicit about how a relying party should validate issued tokens. For the FastAPI wiring itself I cross-referenced the official docs against &lt;a href="https://dorokhovich.com/blog/creating-applications-with-different-access-levels-using-keycloak?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=creating-applications-with-different-access-levels-using-keycloak" rel="noopener noreferrer"&gt;a thorough third-party walkthrough of the realm/client/role setup →&lt;/a&gt;, which screenshots each console step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 1 — a realm as the trust boundary
&lt;/h3&gt;

&lt;p&gt;A realm is an isolated space for users, roles, and clients. In the admin console (typically &lt;code&gt;http://localhost:8080&lt;/code&gt; locally; older builds used the &lt;code&gt;/auth&lt;/code&gt; context path) you open the dropdown top-left, choose &lt;strong&gt;Add realm&lt;/strong&gt;, and name it. Treat the realm as a security boundary, not a namespace: everything inside shares an issuer and a signing key, and nothing outside it is in scope. That boundary is what lets you reason about blast radius at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 2 — a confidential OIDC client, scoped deliberately
&lt;/h3&gt;

&lt;p&gt;Under &lt;strong&gt;Clients → Create client&lt;/strong&gt;, set a client ID and keep the type &lt;strong&gt;OpenID Connect&lt;/strong&gt;. Two settings carry real security weight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client authentication.&lt;/strong&gt; Off for public clients (browser/SPA) that cannot hold a secret; on for confidential backends that can. Our FastAPI backend is confidential, so this is on and it holds a client secret. Getting this wrong is a genuine exposure, not a preference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Valid Redirect URIs.&lt;/strong&gt; This is an allow-list, and it is a control, not a convenience field. An overly broad redirect URI is an open-redirect and token-exfiltration vector, so it is set to the exact callback the app uses — no wildcards, no trailing-slash ambiguity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Control 3 — three roles, least privilege by construction
&lt;/h3&gt;

&lt;p&gt;Under &lt;strong&gt;Roles → Add Role&lt;/strong&gt; we defined the minimum viable set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;admin&lt;/strong&gt; — full administrative access; the smallest possible population.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;user&lt;/strong&gt; — day-to-day application access, no configuration or user management.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;guest&lt;/strong&gt; — read-only access to non-protected data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roles are then assigned per user under &lt;strong&gt;Role Mappings&lt;/strong&gt;. The model is: define once, assign narrowly, enforce centrally. The &lt;a href="https://www.keycloak.org/docs/latest/server_admin/index.html" rel="noopener noreferrer"&gt;Keycloak Server Administration Guide&lt;/a&gt; is the canonical reference for the realm, client, and role screens if you want the exhaustive version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 4 — a single enforcement point that fails closed
&lt;/h3&gt;

&lt;p&gt;This is the control that actually retires the exposure. The &lt;a href="https://github.com/code-specialist/fastapi-keycloak" rel="noopener noreferrer"&gt;&lt;code&gt;fastapi-keycloak&lt;/code&gt; library&lt;/a&gt; wraps the OIDC handshake so that protecting a route is one dependency injection rather than hand-written parsing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Depends&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi_keycloak&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPIKeycloak&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OIDCUser&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;keycloak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPIKeycloak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;server_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8080/auth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-client-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;client_secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-client-secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;realm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-realm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;callback_uri&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8000/callback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/protected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;protected_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OIDCUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keycloak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The security-relevant property is that &lt;code&gt;Depends(keycloak.get_current_user)&lt;/code&gt; validates the token signature and claims against Keycloak and, on failure, rejects the request — it fails closed. The endpoint receives a typed, already-validated &lt;code&gt;OIDCUser&lt;/code&gt;; role gates read from that object. There is no route-local JWT parsing left to get subtly wrong, and the token-validation logic is one implementation rather than 40.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration: reducing risk without a big-bang cutover
&lt;/h2&gt;

&lt;p&gt;Replacing authorization on a live service is itself a high-risk change, so we sequenced it to keep the system defensible at every step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run Keycloak in parallel.&lt;/strong&gt; Both auth paths coexisted for a sprint. New endpoints used the Keycloak dependency; existing routes kept their checks. Because both sides spoke JWTs, a request carried a token either could interpret, so there was no flag-day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mirror existing roles first.&lt;/strong&gt; We recreated &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt;, and &lt;code&gt;guest&lt;/code&gt; to match the old names exactly, so claims mapped one-to-one and we changed the enforcement mechanism without simultaneously changing the policy — one variable at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrate highest-risk routes first.&lt;/strong&gt; The admin endpoints, where a missing control is a real incident, moved first. Each migration was a small, reviewable PR: delete the manual parsing, inject the dependency, gate on the role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the legacy path only after the last route moved.&lt;/strong&gt; A dormant second auth system is standing attack surface. Once every route was on Keycloak, the home-grown decoder was removed entirely rather than left "just in case."&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Centralizing authorization is only worth anything if you can demonstrate it holds. Because the user authenticates against Keycloak, Keycloak issues signed tokens, and the service only validates them, the service never sees a credential — the sensitive material lives in one hardened system and everything downstream verifies signatures and reads claims. That separation is what makes the control auditable: "who can reach this?" becomes a query against role mappings in the console instead of a code archaeology exercise. We paired that with negative tests — a &lt;code&gt;user&lt;/code&gt; token against an &lt;code&gt;admin&lt;/code&gt; route must return 403 — so the absence of a check is now something a test can catch rather than something that ships silently.&lt;/p&gt;

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

&lt;p&gt;Centralizing the authorization decision removes a whole class of omission defects, but it does not make the system risk-free — it relocates the risk, and honesty about where it went is the point.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keycloak is now a high-value single point.&lt;/strong&gt; The blast radius of a realm compromise or a signing-key leak is the whole platform. That trades many small risks for one concentrated one, which is the right trade only if the concentrated point is hardened, patched, and monitored. We track Keycloak security advisories directly rather than waiting for a dependency bump.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect-URI and audience configuration remain security-sensitive.&lt;/strong&gt; A later loosening of an allow-list, or a mismatched &lt;code&gt;aud&lt;/code&gt; mapping, quietly reintroduces exposure. These live in reviewed configuration, not ad-hoc changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public vs confidential client drift.&lt;/strong&gt; A client accidentally set public that needs to hold a secret is a real weakness; we assert client type in configuration review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token lifetime and revocation.&lt;/strong&gt; Short access-token TTLs bound the window a leaked token is useful; we are still tuning refresh-token handling and session invalidation on role change, since a demoted user holding a valid token is a genuine residual gap until the token expires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group and multi-service rollout.&lt;/strong&gt; We are moving to Keycloak groups for team-level role assignment and extending the same realm to a second and third service. Each new relying party is new attack surface that has to be onboarded with the same scrutiny, not rubber-stamped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The net effect is that a single wrong endpoint is no longer one breach away, and the cost of the average authorization mistake has dropped from "incident" to "failing test." For a service still hand-rolling role checks, consolidating onto an IAM authority is among the highest-leverage reductions in attack surface available — provided you then treat that authority as the critical asset it has 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&lt;/a&gt; — canonical reference for realms, clients, roles, and role mappings.&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; — how a relying party should validate issued tokens, and confidential vs public client guidance.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://openid.net/specs/openid-connect-core-1_0.html" rel="noopener noreferrer"&gt;OpenID Connect Core 1.0 specification&lt;/a&gt; — the standard underneath the flow, from the OpenID Foundation.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://owasp.org/Top10/A01_2021-Broken_Access_Control/" rel="noopener noreferrer"&gt;OWASP Top 10 — A01:2021 Broken Access Control&lt;/a&gt; — the risk category this work directly addresses.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/code-specialist/fastapi-keycloak" rel="noopener noreferrer"&gt;fastapi-keycloak on GitHub&lt;/a&gt; — the library providing &lt;code&gt;FastAPIKeycloak&lt;/code&gt;, &lt;code&gt;OIDCUser&lt;/code&gt;, and the &lt;code&gt;get_current_user&lt;/code&gt; dependency.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/creating-applications-with-different-access-levels-using-keycloak?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=creating-applications-with-different-access-levels-using-keycloak" rel="noopener noreferrer"&gt;A screenshot-by-screenshot Keycloak + FastAPI setup writeup&lt;/a&gt; — a useful third-party walkthrough of the console steps to keep open while wiring the realm and client.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>cybersecurity</category>
      <category>security</category>
    </item>
    <item>
      <title>When Your Resilience Vendor Is Your Single Point of Failure: A Multi-CDN Failover Threat Model After the November 2025 Cloudflare Outage</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Sun, 02 Aug 2026 08:39:12 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/when-your-resilience-vendor-is-your-single-point-of-failure-a-multi-cdn-failover-threat-model-3kii</link>
      <guid>https://dev.to/gaberialsofie/when-your-resilience-vendor-is-your-single-point-of-failure-a-multi-cdn-failover-threat-model-3kii</guid>
      <description>&lt;h2&gt;
  
  
  The exposure
&lt;/h2&gt;

&lt;p&gt;At 11:20 UTC on November 18, 2025, Cloudflare fell over, and our stack fell over with it. Every dashboard we owned threw HTTP 500, 522, and 530. The detail that reframed the whole morning: when we tried to log into Cloudflare to disable a WAF rule and route around the problem, the Cloudflare dashboard itself was down. We were locked out of the controls that could have saved us.&lt;/p&gt;

&lt;p&gt;It is worth stating the exposure in security terms, not just operational ones, because "the vendor had a bad day" is the wrong lesson. Availability is a security property — it sits alongside confidentiality and integrity — and a &lt;strong&gt;multi-CDN failover&lt;/strong&gt; posture is the control that protects it against a single-provider failure. What the outage actually revealed was that our "resilient" architecture had exactly one point of failure, and it was the vendor whose entire job was resilience. The event hit an estimated 20% of all HTTP requests, roughly 7 million websites, and 30% of the top 1,000 sites; per &lt;a href="https://blog.cloudflare.com/18-november-2025-outage/" rel="noopener noreferrer"&gt;Cloudflare's own post-mortem&lt;/a&gt;, a database permissions change caused a Bot Management feature file to double in size and blow past a hardcoded limit, crashing the traffic-routing software across the network and dragging the control plane down with it. I kept that post-mortem in one tab and &lt;a href="https://dorokhovich.com/blog/cloudflare-outage-november-2025?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=cloudflare-outage-november-2025" rel="noopener noreferrer"&gt;a thorough third-party writeup of how one team scrambled and rebuilt afterward →&lt;/a&gt; in another while reworking our own single points of failure.&lt;/p&gt;

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

&lt;p&gt;Blaming the vendor is comfortable and useless. The outage exposed our own decisions, and naming them as threats is what made them fixable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single-provider control-plane failure.&lt;/strong&gt; Cloudflare was authoritative DNS for every zone and proxied every hostname. When its control plane died we had zero ability to re-point anything — the failure of one provider was a total loss of both traffic routing and our ability to intervene.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cascading failure with the control plane as victim.&lt;/strong&gt; The overload did not stay contained; it rippled outward until the very system needed to recover was itself down — the textbook pattern &lt;a href="https://sre.google/sre-book/addressing-cascading-failures/" rel="noopener noreferrer"&gt;Google's SRE book describes in its chapter on cascading failures&lt;/a&gt;. A recovery mechanism that shares a fate with the thing it recovers is not a recovery mechanism.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Undersized origins behind a cache.&lt;/strong&gt; Origins sized for cache-hit traffic collapse in minutes when the CDN stops absorbing load — turning a CDN outage into an origin outage, a second-order failure we had built in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Responder lockout via SSO through the failing provider.&lt;/strong&gt; Engineers authenticated to internal tooling through Cloudflare Access. With Access degraded, the people who needed to respond could not reach the tools to do it. Concentrating the response path behind the same provider as the incident is an access-control failure mode, not just an availability one.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;We gave ourselves a six-week sprint, in priority order.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 1 — secondary DNS, so no one control plane can strand us
&lt;/h3&gt;

&lt;p&gt;DNS is the first and cheapest line of defense. We moved to a dual-provider authoritative setup — Cloudflare DNS primary, AWS Route 53 secondary — with both providers' nameservers registered at the registrar and both zones synchronized from a single Terraform source of truth so they cannot drift. Health checks follow the active-passive pattern &lt;a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-types.html" rel="noopener noreferrer"&gt;AWS documents for Route 53 DNS failover&lt;/a&gt;. The point is not that Route 53 is "better" — it is that no single provider's control plane can strand us anymore.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 2 — a warm CDN fallback, not a slide-deck one
&lt;/h3&gt;

&lt;p&gt;Cloudflare stays primary; AWS CloudFront is a pre-configured, continuously smoke-tested secondary, so "activate fallback" is a DNS weight change rather than a two-day build under fire. A fallback you have never exercised is a hypothesis, not a control.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 3 — a direct-origin escape hatch
&lt;/h3&gt;

&lt;p&gt;We created a permanent, un-proxied path to origin: &lt;code&gt;origin.ourdomain.com&lt;/code&gt; with proxying disabled, a 24-hour TTL so the record is cached far and wide before we ever need it, and documented copy-paste commands in the runbook. The security-critical detail is the certificate: the escape hatch carries a real, publicly-trusted TLS cert, &lt;strong&gt;not&lt;/strong&gt; a Cloudflare Origin CA cert. &lt;a href="https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/" rel="noopener noreferrer"&gt;Cloudflare's own SSL/TLS docs&lt;/a&gt; note that visitors hit untrusted-certificate errors the moment you disable proxying — which is exactly useless when Cloudflare is the thing that is down. An Origin CA cert only chains behind Cloudflare, so a failover that relies on it fails on TLS at the worst possible moment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 4 — monitoring that does not depend on the thing it watches
&lt;/h3&gt;

&lt;p&gt;Monitoring services &lt;em&gt;through&lt;/em&gt; the CDN is like testing a smoke detector by asking the fire. We moved to direct-IP health checks (HTTP and TCP) against origin, origin-domain monitoring separate from the public hostname, two independent providers (UptimeRobot and Pingdom) so a single monitoring vendor blip does not blind us, and synthetic checks on the real login and checkout paths. Lost visibility during an incident is lost detection, so the monitoring path is deliberately decoupled from the serving path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Control 5 — break-glass authentication outside the failing provider
&lt;/h3&gt;

&lt;p&gt;This is the access-control control, and the one most directly in my lane. We kept Cloudflare Access for day-to-day SSO but added backup OIDC providers (Google Workspace and GitHub) wired up ahead of time, &lt;strong&gt;break-glass accounts that live entirely outside Cloudflare Access&lt;/strong&gt;, and a one-page emergency-access document every on-call engineer holds offline. The principle is that the response path must never share a single point of failure with the incident — if the way in to fix things depends on the thing that broke, you have no way in.&lt;/p&gt;

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

&lt;p&gt;Infrastructure changes are worthless if nobody knows the sequence at 11:20 on a Tuesday, so the controls are backed by a timed runbook we drill quarterly against a deliberately broken staging environment: minute 0-5 detect and verify origin health independently; minute 5-15 flip DNS weight, activate the fallback CDN, enable origin-direct for critical paths; minute 15-30 communicate over a channel that is not behind the failing CDN; minute 30+ stabilize and start the post-mortem timeline. The drills are the verification — our first one took 40 minutes because nobody remembered where the break-glass doc lived, which is precisely the kind of finding you want in a drill and not in an incident. A simulated CDN loss now recovers to redirected traffic in under four minutes instead of three-plus hours hard down, but that number is only true as long as the next drill confirms it.&lt;/p&gt;

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

&lt;p&gt;The rebuild removed the single-provider single point of failure, but resilience work relocates risk rather than eliminating it, and the honest edges matter.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The escape-hatch certificate and TTL cut both ways.&lt;/strong&gt; A publicly-trusted cert on &lt;code&gt;origin.&lt;/code&gt; must be renewed and monitored like any production cert — an expired escape-hatch cert is a silent failure that only surfaces during the incident it was meant to survive. The 24-hour TTL that makes the record widely cached also means a &lt;em&gt;mistake&lt;/em&gt; on that record lingers, so it changes only through reviewed IaC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin autoscaling lag.&lt;/strong&gt; When the CDN stops shielding origin, direct traffic is spikier than models predict. Pre-warming capacity is in the runbook, but the residual is that a large enough spike still outruns the scaler; we are tuning headroom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drills decay.&lt;/strong&gt; A runbook is only as good as the last rehearsal, and quarterly is a compromise — long enough that details rot between drills. We watch drill times as the leading indicator of whether the control still works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hidden third-party dependencies.&lt;/strong&gt; The scariest dependencies are the ones you did not know you had until they fail. We are mapping every third-party dependency in the stack and moving toward light chaos engineering — deliberately black-holing the primary CDN in staging on a schedule — to surface them before an outage does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding providers adds surface.&lt;/strong&gt; A second DNS provider, a second CDN, and backup OIDC providers each expand the set of vendors whose compromise or misconfiguration could affect us. More redundancy is more attack surface, so each addition is hardened and its credentials scoped rather than assumed benign.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mindset shift the outage forced is the durable part: design assuming every provider will fail, then make the failure boring. What we keep watching is the gap between "we rehearsed this" and "the fire came at a time we did not pick" — because you only ever control the second half of that.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://blog.cloudflare.com/18-november-2025-outage/" rel="noopener noreferrer"&gt;Cloudflare outage on November 18, 2025 — official post-mortem&lt;/a&gt; — the authoritative timeline and root-cause analysis, straight from Cloudflare.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sre.google/sre-book/addressing-cascading-failures/" rel="noopener noreferrer"&gt;Google SRE Book — Addressing Cascading Failures&lt;/a&gt; — the mental model for why an overload takes down far more than the thing that was overloaded.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-types.html" rel="noopener noreferrer"&gt;AWS — Active-active and active-passive Route 53 DNS failover&lt;/a&gt; — the reference for the secondary-DNS failover pattern we adopted.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/" rel="noopener noreferrer"&gt;Cloudflare SSL/TLS docs — Origin CA certificates&lt;/a&gt; — why an Origin CA cert can't back a direct-to-origin escape hatch.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dorokhovich.com/blog/cloudflare-outage-november-2025?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=cloudflare-outage-november-2025" rel="noopener noreferrer"&gt;A hands-on writeup of a multi-CDN failover rebuild&lt;/a&gt; — a useful third-party field-notes version of this failover work to keep next to a runbook.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>infrastructure</category>
      <category>security</category>
      <category>sre</category>
    </item>
    <item>
      <title>k3d, kind, Helm, Kustomize, Tilt, Skaffold, k9s: a one-screen map of who does what</title>
      <dc:creator>Gaberial Sofie</dc:creator>
      <pubDate>Thu, 30 Jul 2026 09:38:26 +0000</pubDate>
      <link>https://dev.to/gaberialsofie/devto-syndication-reddit-rkubernetes-tool-comparison-posts-perform-well-as-reference-bookmarks-ip2</link>
      <guid>https://dev.to/gaberialsofie/devto-syndication-reddit-rkubernetes-tool-comparison-posts-perform-well-as-reference-bookmarks-ip2</guid>
      <description>&lt;p&gt;Drowning in the local-Kubernetes tool zoo? Chapter 3 of the series sorts them onto shelves so you can see that you probably only need two of them. It's reference material, deliberately opinionated-but-fair.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two fundamentally different approaches.&lt;/strong&gt; (1) Run all of Kubernetes locally (k3d/kind/minikube). (2) Connect your local process to a &lt;em&gt;remote&lt;/em&gt; cluster (Telepresence/mirrord/Gefyra). The series takes approach 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foundation layer:&lt;/strong&gt; Docker (local cluster nodes are just Docker containers with Kubernetes inside) + kubectl (the official CLI everything else calls under the hood; it has Kustomize built in via &lt;code&gt;-k&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local clusters:&lt;/strong&gt; k3d (k3s in Docker — fastest start, lowest memory, built-in registry), kind (upstream K8s, CNCF-certified, what the K8s project uses for CI/conformance), minikube (VM/driver + rich addons, great for learning). Consensus for dev: kind or k3d.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manifest format:&lt;/strong&gt; Helm (package manager — charts, templating, releases, rollbacks; ~75% of orgs, Helm 4 shipped Nov 2025) vs Kustomize (template-free base + overlays, built into kubectl). They complement more than compete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inner-loop accelerators:&lt;/strong&gt; Tilt (Live Update + rich web UI, Tiltfile in Starlark), Skaffold (Google, YAML, CLI), DevSpace (two-way sync, YAML, CLI). Okteto and Garden solve adjacent problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The remote tools differ meaningfully:&lt;/strong&gt; only Telepresence needs root; Telepresence and mirrord can swap files/env (Gefyra can't); only mirrord handles several local processes at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helpers:&lt;/strong&gt; k9s (terminal UI / "visual kubectl", colour-code prod red so you don't nuke it), kubectx/kubens, stern, krew.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why the series picks k3d + Tilt:&lt;/strong&gt; real K8s API + fast/cheap recreate, plus a minutes→seconds loop and a beginner-friendly dashboard. Both OSS and local.&lt;/li&gt;
&lt;/ul&gt;

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

</description>
      <category>devops</category>
      <category>docker</category>
      <category>infrastructure</category>
      <category>kubernetes</category>
    </item>
  </channel>
</rss>
