<?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: iapilgrim</title>
    <description>The latest articles on DEV Community by iapilgrim (@pilgrim2go).</description>
    <link>https://dev.to/pilgrim2go</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%2F36351%2Fd3ac7864-d102-4f7a-abfa-c2ca03495434.png</url>
      <title>DEV Community: iapilgrim</title>
      <link>https://dev.to/pilgrim2go</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pilgrim2go"/>
    <language>en</language>
    <item>
      <title>GCP The Hard Way — Part 8: Designing a Broken System for Someone Else to Debug</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:39:05 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/gcp-the-hard-way-part-8-designing-a-broken-system-for-someone-else-to-debug-4m74</link>
      <guid>https://dev.to/pilgrim2go/gcp-the-hard-way-part-8-designing-a-broken-system-for-someone-else-to-debug-4m74</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Every prior post in this series involved diagnosing a failure someone&lt;br&gt;
else (in this case, the author) had designed. This final post reverses&lt;br&gt;
that relationship: you design the failure. Constructing a realistic,&lt;br&gt;
diagnosable incident requires a deeper understanding of a system than&lt;br&gt;
fixing one does, because you must accurately predict which symptoms a&lt;br&gt;
given misconfiguration will produce. This exercise is a common&lt;br&gt;
technique in SRE training programs, and this post walks through&lt;br&gt;
applying it to a Google Cloud environment.&lt;/p&gt;
&lt;h2&gt;
  
  
  Solution overview
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────┐        ┌────────────────────┐
│   Design Phase        │        │    Debug Phase       │
│                        │───────▶│                       │
│  - Inject 3–5 faults   │ hand   │  - Investigate via    │
│  - Write incident      │  off   │    logs and metrics   │
│    ticket (symptoms     │        │  - Isolate root cause │
│    only, no root cause) │        │  - Remediate           │
└─────────────────────┘        └────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Criteria for a well-designed failure scenario
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clear symptom, unclear cause&lt;/strong&gt; — the observer sees a concrete
behavior, not an obvious misconfiguration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliably reproducible&lt;/strong&gt; — the failure occurs consistently, not
intermittently by chance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plausible red herrings present&lt;/strong&gt; — realistic incidents involve
ruling out multiple hypotheses, not confirming the first guess&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A definitive verification method exists&lt;/strong&gt; — once the correct
hypothesis is identified, there must be an unambiguous way to
confirm it&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Walkthrough
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Step 1: Establish a known-good baseline
&lt;/h3&gt;

&lt;p&gt;Reuse the three-tier architecture from Part 2 (Load Balancer → MIG →&lt;br&gt;
Cloud SQL), and confirm it functions correctly before introducing any&lt;br&gt;
faults — this establishes a clean baseline to distinguish injected&lt;br&gt;
faults from pre-existing configuration issues.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 2: Select and inject 3–5 faults
&lt;/h3&gt;

&lt;p&gt;A representative fault bank, spanning difficulty levels:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fault&lt;/th&gt;
&lt;th&gt;Injection&lt;/th&gt;
&lt;th&gt;External symptom&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Firewall allows only one of two required health-check ranges&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--source-ranges=130.211.0.0/22&lt;/code&gt; (omitting &lt;code&gt;35.191.0.0/16&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Intermittent backend health flapping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firewall target tag mismatch&lt;/td&gt;
&lt;td&gt;Firewall applies &lt;code&gt;web-server&lt;/code&gt;; VM tagged &lt;code&gt;app-server&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Connection timeout, resembling an application crash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud SQL connection limit set below expected concurrent load&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--database-flags=max_connections=20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Failures only under concurrent load, invisible to single-request testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read replica under-provisioned relative to write volume&lt;/td&gt;
&lt;td&gt;Small tier replica against high write throughput&lt;/td&gt;
&lt;td&gt;Stale reads that resemble an application logic bug&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Example injection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud sql instances patch app-db &lt;span class="nt"&gt;--database-flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;max_connections&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose faults spanning multiple categories (network, IAM, database,&lt;br&gt;
application) rather than concentrating on one area, to keep the&lt;br&gt;
diagnostic exercise varied.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 3: Write the incident ticket
&lt;/h3&gt;

&lt;p&gt;The ticket should be written from the perspective of an end user&lt;br&gt;
reporting a problem, deliberately omitting any technical language that&lt;br&gt;
would hint at the root cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Ticket #001: Intermittent login failures during peak hours&lt;/span&gt;

&lt;span class="gu"&gt;## Description&lt;/span&gt;
Users report occasional "connection timeout" errors when logging in,
primarily between 9–10 AM. No issues reported in the afternoon.

&lt;span class="gu"&gt;## Already investigated&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; VM restarted — issue persists
&lt;span class="p"&gt;-&lt;/span&gt; CPU and memory utilization checked — both within normal range

&lt;span class="gu"&gt;## Access provided&lt;/span&gt;
Cloud Console (read-only), Cloud Logging, gcloud CLI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Including one or two already-ruled-out hypotheses ("already&lt;br&gt;
investigated") makes the exercise more representative of real&lt;br&gt;
incident response, where an initial theory is often incorrect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Grant scoped access for the investigator
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud projects add-iam-policy-binding &amp;lt;PROJECT_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"user:&amp;lt;investigator-email&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/viewer"&lt;/span&gt;

gcloud projects add-iam-policy-binding &amp;lt;PROJECT_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"user:&amp;lt;investigator-email&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/logging.viewer"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read-only access prevents accidental remediation before the&lt;br&gt;
investigator has confirmed a root cause.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Structured investigation approach
&lt;/h3&gt;

&lt;p&gt;Whether debugging as a peer or returning to your own system after a&lt;br&gt;
delay, apply a consistent process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce the symptom first&lt;/strong&gt; — do not make changes before
confirming the reported behavior&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narrow scope by elimination&lt;/strong&gt; — isolate whether the fault sits in
the network, application, or database layer by testing each layer
independently (e.g., &lt;code&gt;curl localhost&lt;/code&gt; on the VM before testing
through the load balancer)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read logs systematically&lt;/strong&gt; — filter Cloud Logging by severity and
correlate timestamps with reported symptom occurrence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify each hypothesis definitively&lt;/strong&gt; before moving to the next,
rather than changing multiple variables simultaneously&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Comparing outcomes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Injected fault | Identified? | Time to identify | False leads pursued |
|---|---|---|---|
| Fault 1 | | | |
| Fault 2 | | | |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Clean up resources
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud projects delete &amp;lt;PROJECT_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This series has moved through the core operational competencies of&lt;br&gt;
infrastructure engineering: provisioning without code changes,&lt;br&gt;
diagnosing availability failures, auditing access control, choosing&lt;br&gt;
data stores appropriate to workload characteristics, shipping releases&lt;br&gt;
safely, validating disaster recovery assumptions, controlling cost, and&lt;br&gt;
finally, designing failure scenarios well enough to teach them to&lt;br&gt;
others. That last skill — being able to construct a realistic incident,&lt;br&gt;
not just resolve one — is a strong signal of systems-level understanding,&lt;br&gt;
and a capability directly transferable to building internal SRE&lt;br&gt;
training programs.&lt;/p&gt;

&lt;p&gt;This concludes the &lt;em&gt;Google Cloud Fundamentals: The Hard Way&lt;/em&gt; series.&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>sre</category>
      <category>incidentresponse</category>
      <category>finale</category>
    </item>
    <item>
      <title>GCP The Hard Way — Part 7: Finding and Eliminating Cloud Waste</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:38:54 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/gcp-the-hard-way-part-7-finding-and-eliminating-cloud-waste-26n9</link>
      <guid>https://dev.to/pilgrim2go/gcp-the-hard-way-part-7-finding-and-eliminating-cloud-waste-26n9</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Cloud cost inefficiency rarely comes from a single dramatic error —&lt;br&gt;
it accumulates from small, individually reasonable-looking decisions:&lt;br&gt;
an oversized VM chosen "to be safe," a static IP reserved for a test&lt;br&gt;
that was never cleaned up, a storage bucket without a lifecycle&lt;br&gt;
policy. This post deliberately introduces four common sources of cloud&lt;br&gt;
waste, lets them run long enough to appear in billing data, and walks&lt;br&gt;
through identifying and remediating each using Billing Reports and&lt;br&gt;
Recommender.&lt;/p&gt;
&lt;h2&gt;
  
  
  Solution overview
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────┐
│                 Sources of waste               │
├─────────────────────────────────────────────┤
│ 1. Oversized Compute Engine instance           │
│ 2. Cloud Storage without lifecycle policy      │
│ 3. Reserved static IP with no attached resource │
│ 4. Autoscaling ceiling set without usage data   │
└─────────────────────────────────────────────┘
                    │
                    ▼
        ┌───────────────────────┐
        │   Billing Reports +     │
        │   Recommender API       │
        └───────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Budget Alert configured on the billing account (recommended
threshold: $25) before beginning&lt;/li&gt;
&lt;li&gt;At least 3–4 days of elapsed time between provisioning and analysis,
since billing trend data needs a meaningful window to be useful&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Walkthrough
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Step 1: Provision the waste sources
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Oversized instance for a lightweight workload:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances create oversized-vm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;n2-standard-8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-12 &lt;span class="nt"&gt;--image-project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-cloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Storage without a lifecycle policy:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud storage buckets create gs://&amp;lt;PROJECT_ID&amp;gt;-waste-bucket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Idle static IP:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute addresses create unused-static-ip &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Autoscaling ceiling set without usage justification:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instance-groups managed set-autoscaling waste-mig &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--max-num-replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20 &lt;span class="nt"&gt;--min-num-replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-cpu-utilization&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Allow this configuration to run for 3–4 days before proceeding to&lt;br&gt;
analysis.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Review Billing Reports
&lt;/h3&gt;

&lt;p&gt;In &lt;strong&gt;Billing → Reports&lt;/strong&gt;, filter by the time range covering the&lt;br&gt;
provisioning period and group by Service, then by SKU for finer&lt;br&gt;
granularity. Rank the four sources by actual cost — results frequently&lt;br&gt;
diverge from intuition; for example, an idle static IP typically costs&lt;br&gt;
far less than an oversized VM, but this should be confirmed against&lt;br&gt;
your own data rather than assumed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Query Recommender for automated findings
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud recommender recommendations list &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;PROJECT_ID&amp;gt; &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--recommender&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;google.compute.instance.MachineTypeRecommender

gcloud recommender recommendations list &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;PROJECT_ID&amp;gt; &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;global &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--recommender&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;google.compute.address.IdleResourceRecommender
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Coverage gap to note:&lt;/strong&gt; Recommender does not currently surface&lt;br&gt;
missing storage lifecycle policies as a recommendation — this class of&lt;br&gt;
waste requires manual review of bucket configuration and billing&lt;br&gt;
trends, reinforcing that automated tooling should supplement, not&lt;br&gt;
replace, periodic manual cost review.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Remediate
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances stop oversized-vm &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a
gcloud compute instances set-machine-type oversized-vm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;e2-small
gcloud compute instances start oversized-vm &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; lifecycle.json &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
{
  "rule": [
    {"action": {"type": "SetStorageClass", "storageClass": "COLDLINE"},
     "condition": {"age": 30}},
    {"action": {"type": "Delete"}, "condition": {"age": 365}}
  ]
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;gcloud storage buckets update gs://&amp;lt;PROJECT_ID&amp;gt;-waste-bucket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--lifecycle-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;lifecycle.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute addresses delete unused-static-ip &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1 &lt;span class="nt"&gt;--quiet&lt;/span&gt;

gcloud compute instance-groups managed set-autoscaling waste-mig &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--max-num-replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="nt"&gt;--min-num-replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-cpu-utilization&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The revised autoscaling ceiling of 4 should be justified by observed&lt;br&gt;
peak usage during the test window, not set arbitrarily — an&lt;br&gt;
unjustifiably high ceiling creates cost exposure risk even without&lt;br&gt;
generating cost directly, since a traffic spike or application bug&lt;br&gt;
could scale the fleet far beyond actual need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring savings
&lt;/h2&gt;

&lt;p&gt;Allow 1–2 additional days after remediation, then compare Billing&lt;br&gt;
Reports for the same resource categories before and after the change:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Cost before (monthly, extrapolated)&lt;/th&gt;
&lt;th&gt;Cost after&lt;/th&gt;
&lt;th&gt;% reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compute (VM resize)&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage (lifecycle policy)&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle static IP + LB&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autoscaling ceiling&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Clean up resources
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances delete oversized-vm &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud storage &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; gs://&amp;lt;PROJECT_ID&amp;gt;-waste-bucket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Automated recommenders are a useful first pass, but this exercise&lt;br&gt;
shows they have blind spots — notably storage lifecycle policies — that&lt;br&gt;
still require deliberate, periodic manual review. Building this review&lt;br&gt;
into a regular cadence, rather than a one-time cleanup, is the&lt;br&gt;
practical difference between temporary savings and sustained cost&lt;br&gt;
efficiency.&lt;/p&gt;

&lt;p&gt;In Part 8, we close the series by reversing roles: designing a&lt;br&gt;
broken system for someone else — or a future version of yourself — to&lt;br&gt;
diagnose.&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>finops</category>
      <category>billing</category>
      <category>costoptimization</category>
    </item>
    <item>
      <title>GCP The Hard Way — Part 6: Validating Disaster Recovery Assumptions With a Live Drill</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:38:33 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/gcp-the-hard-way-part-6-validating-disaster-recovery-assumptions-with-a-live-drill-1191</link>
      <guid>https://dev.to/pilgrim2go/gcp-the-hard-way-part-6-validating-disaster-recovery-assumptions-with-a-live-drill-1191</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Disaster recovery plans are frequently validated on paper and rarely&lt;br&gt;
tested end-to-end, which means the first real test often happens&lt;br&gt;
during an actual incident. This post walks through provisioning&lt;br&gt;
cross-region Cloud SQL replication and dual-region storage, then&lt;br&gt;
deliberately removing the primary region's infrastructure to measure&lt;br&gt;
actual Recovery Time Objective (RTO) and Recovery Point Objective&lt;br&gt;
(RPO) against a documented estimate made in advance.&lt;/p&gt;
&lt;h2&gt;
  
  
  Solution overview
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Region: asia-southeast1 (Primary)      Region: asia-east1 (DR)
┌─────────────────────┐               ┌─────────────────────┐
│  Compute Engine VM    │              │                       │
│  Cloud SQL (Primary)  │──replication▶│  Cloud SQL (Replica)  │
└─────────────────────┘               └─────────────────────┘
         │                                        
         ▼                                        
┌─────────────────────────────────┐
│  Cloud Storage (dual-region: ASIA1) │
└─────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two target regions selected for primary and DR roles&lt;/li&gt;
&lt;li&gt;Cloud DNS zone already configured for the application domain&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Walkthrough
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Step 1: Document your recovery estimate before building anything
&lt;/h3&gt;

&lt;p&gt;Before provisioning any infrastructure, record a written estimate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Estimated RTO: ____ minutes
Estimated RPO: ____ seconds/minutes
Anticipated recovery steps: 1. 2. 3.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not revise this document until after the drill — the comparison at&lt;br&gt;
the end is the primary value of this exercise.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 2: Provision cross-region replication
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud sql instances create app-db-primary &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--database-version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;MYSQL_8_0 &lt;span class="nt"&gt;--tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;db-g1-small &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1 &lt;span class="nt"&gt;--backup&lt;/span&gt; &lt;span class="nt"&gt;--enable-bin-log&lt;/span&gt;

gcloud sql instances create app-db-replica-dr &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--master-instance-name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-db-primary &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-east1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;db-g1-small
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Step 3: Provision dual-region storage
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud storage buckets create gs://&amp;lt;PROJECT_ID&amp;gt;-dr-bucket &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ASIA1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Step 4: Deploy the application and point DNS at the primary region
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances create app-vm-primary &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;e2-small &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-12 &lt;span class="nt"&gt;--image-project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-cloud

gcloud dns record-sets create app.yourdomain-test.com. &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-dr-zone &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;A &lt;span class="nt"&gt;--ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;300 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rrdatas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;PRIMARY_VM_IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A 300-second TTL is used deliberately — higher TTLs common in default&lt;br&gt;
registrar configurations directly extend recovery time by keeping&lt;br&gt;
stale DNS resolutions cached at clients and resolvers.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 5: Simulate the disaster
&lt;/h3&gt;

&lt;p&gt;Record the exact start time before proceeding — this is your RTO&lt;br&gt;
measurement baseline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +&lt;span class="s2"&gt;"%Y-%m-%dT%H:%M:%SZ"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; disaster-start-time.txt

gcloud compute instances delete app-vm-primary &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud sql instances delete app-db-primary &lt;span class="nt"&gt;--quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deleting the primary instance also orphans the read replica — a&lt;br&gt;
realistic condition that must be handled as part of recovery.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 6: Execute recovery without referencing the original plan
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud sql instances promote-replica app-db-replica-dr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Deploy the application in the DR region:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances create app-vm-dr &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-east1-a &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;e2-small &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-12 &lt;span class="nt"&gt;--image-project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-cloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected friction point:&lt;/strong&gt; connection strings hardcoded to the&lt;br&gt;
original instance name will need manual updates once&lt;br&gt;
&lt;code&gt;app-db-replica-dr&lt;/code&gt; becomes the new writable primary. This is a direct&lt;br&gt;
argument for externalizing connection configuration (via Secret&lt;br&gt;
Manager or environment variables) rather than embedding it in&lt;br&gt;
application code or images.&lt;/p&gt;

&lt;p&gt;Update DNS to point at the DR region, and record the completion time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud dns record-sets update app.yourdomain-test.com. &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-dr-zone &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;A &lt;span class="nt"&gt;--ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;300 &lt;span class="nt"&gt;--rrdatas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;DR_VM_IP&amp;gt;

&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +&lt;span class="s2"&gt;"%Y-%m-%dT%H:%M:%SZ"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; recovery-end-time.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify DNS resolution independently (e.g., with &lt;code&gt;dig&lt;/code&gt;) rather than&lt;br&gt;
assuming propagation is instantaneous once the record is updated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Measure RPO
&lt;/h3&gt;

&lt;p&gt;Cloud SQL read replicas replicate asynchronously; any writes to the&lt;br&gt;
primary that had not yet replicated at the moment of deletion are&lt;br&gt;
lost. To quantify this in a follow-up test run, write a record&lt;br&gt;
immediately before deleting the primary and confirm whether it is&lt;br&gt;
present in the promoted replica — this delta is your empirical RPO.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing estimate to reality
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Documented estimate&lt;/th&gt;
&lt;th&gt;Measured result&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RTO&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RPO&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most teams running this drill for the first time find their initial&lt;br&gt;
estimate omitted at least one dependency — commonly DNS propagation&lt;br&gt;
delay or hardcoded connection configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean up resources
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances delete app-vm-dr &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-east1-a &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud sql instances delete app-db-replica-dr &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud storage &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; gs://&amp;lt;PROJECT_ID&amp;gt;-dr-bucket
gcloud dns managed-zones delete app-dr-zone &lt;span class="nt"&gt;--quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Replication and backups reduce data loss risk but do not eliminate&lt;br&gt;
it — RPO is bounded by replication lag, not by the mere existence of a&lt;br&gt;
replica. The most valuable output of this exercise isn't the&lt;br&gt;
architecture itself, but the revised recovery runbook you write&lt;br&gt;
immediately afterward, informed by what your original plan missed.&lt;/p&gt;

&lt;p&gt;In Part 7, we shift from availability to cost, examining how&lt;br&gt;
common infrastructure choices translate into avoidable cloud spend.&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>disasterrecovery</category>
      <category>cloudsql</category>
      <category>multiregion</category>
    </item>
    <item>
      <title>GCP The Hard Way — Part 5: Canary Deployments Against a Delayed-Failure Bug</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:38:16 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/gcp-the-hard-way-part-5-canary-deployments-against-a-delayed-failure-bug-90a</link>
      <guid>https://dev.to/pilgrim2go/gcp-the-hard-way-part-5-canary-deployments-against-a-delayed-failure-bug-90a</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A deployment that reports success and a release that doesn't harm&lt;br&gt;
users are not the same thing. This distinction matters most when a bug&lt;br&gt;
doesn't manifest immediately — memory leaks, connection pool&lt;br&gt;
exhaustion, and similar failure modes often pass initial health checks&lt;br&gt;
before degrading. This post deploys a workload to Google Kubernetes&lt;br&gt;
Engine with correctly configured liveness and readiness probes,&lt;br&gt;
introduces a version with a 30-second delayed failure, and walks&lt;br&gt;
through rollback and canary rollout as complementary mitigation&lt;br&gt;
strategies.&lt;/p&gt;
&lt;h2&gt;
  
  
  Solution overview
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────┐      ┌──────────────────────┐
│   Ingress /   │────▶│   Service: myapp       │
│ Load Balancer │      └──────────┬───────────┘
└─────────────┘                  │
                    ┌─────────────┴─────────────┐
                    ▼                             ▼
          ┌───────────────────┐        ┌───────────────────┐
          │ Deployment: stable  │        │ Deployment: canary  │
          │  (9 replicas, v1)   │        │  (1 replica, v2)     │
          └───────────────────┘        └───────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A GKE Standard cluster with &lt;code&gt;kubectl&lt;/code&gt; configured&lt;/li&gt;
&lt;li&gt;Artifact Registry for container image storage&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Walkthrough
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Step 1: Provision the cluster and registry
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud services &lt;span class="nb"&gt;enable &lt;/span&gt;container.googleapis.com artifactregistry.googleapis.com

gcloud artifacts repositories create app-repo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--repository-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;docker &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1

gcloud container clusters create app-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--num-nodes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;e2-medium

gcloud container clusters get-credentials app-cluster &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Step 2: Build an application with a controllable failure mode
&lt;/h3&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;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&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;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;START_TIME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;BUG_MODE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;BUG_MODE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/health&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;health&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;BUG_MODE&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;START_TIME&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UNHEALTHY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This mirrors real-world delayed failures more closely than a bug that&lt;br&gt;
fails immediately at startup, which would be caught before any traffic&lt;br&gt;
is served.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 3: Deploy with production-grade probes
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/health&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/health&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deployment.yaml
kubectl get pods &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Step 4: Roll out the faulty version and observe
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deployment-v2-bugged.yaml
kubectl get pods &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;For the first ~30 seconds, pods report &lt;code&gt;Running&lt;/code&gt; and &lt;code&gt;Ready&lt;/code&gt; — this is&lt;br&gt;
expected, and is precisely the window in which a naive smoke test&lt;br&gt;
would report success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnosing probe behavior:&lt;/strong&gt; After 30 seconds, inspect events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe pod &amp;lt;pod-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;readinessProbe&lt;/code&gt; failures remove a pod from the Service's Endpoints&lt;br&gt;
list without restarting the container; &lt;code&gt;livenessProbe&lt;/code&gt; failures cause&lt;br&gt;
the kubelet to kill and restart the container, visible as an&lt;br&gt;
increasing &lt;code&gt;RESTARTS&lt;/code&gt; count in &lt;code&gt;kubectl get pods&lt;/code&gt;. Because this&lt;br&gt;
example uses the same path for both probes, expect a repeating&lt;br&gt;
restart cycle consistent with &lt;code&gt;CrashLoopBackOff&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 5: Roll back
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl rollout undo deployment/myapp
kubectl rollout status deployment/myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Measure the elapsed time from issuing the rollback to full traffic&lt;br&gt;
stability — this is your empirical Mean Time to Recovery (MTTR) for&lt;br&gt;
this failure class.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 6: Implement canary rollout for the next release
&lt;/h3&gt;

&lt;p&gt;Rather than replacing the entire fleet at once, split traffic across&lt;br&gt;
two Deployments sharing a Service selector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deployment-stable.yaml   &lt;span class="c"&gt;# 9 replicas, v1&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deployment-canary.yaml   &lt;span class="c"&gt;# 1 replica, v2 (bug fixed)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 50&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://&amp;lt;EXTERNAL_IP&amp;gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"v[12]-[a-z]*"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the observed traffic split approximates the 9:1 replica ratio&lt;br&gt;
before progressively shifting more replicas to the canary track.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean up resources
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete deployment myapp-stable myapp-canary &lt;span class="nt"&gt;--ignore-not-found&lt;/span&gt;
kubectl delete service myapp-service &lt;span class="nt"&gt;--ignore-not-found&lt;/span&gt;
gcloud container clusters delete app-cluster &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Readiness and liveness probes solve different problems and produce&lt;br&gt;
different Kubernetes behaviors — conflating the two is a common source&lt;br&gt;
of confusion when debugging rollout issues. Combined with a canary&lt;br&gt;
rollout strategy, this pattern limits the blast radius of exactly the&lt;br&gt;
kind of delayed-failure bug demonstrated here.&lt;/p&gt;

&lt;p&gt;In Part 6, we validate disaster recovery assumptions by&lt;br&gt;
deliberately deleting a region's infrastructure and measuring actual&lt;br&gt;
recovery time against a documented plan.&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>kubernetes</category>
      <category>gke</category>
      <category>cicd</category>
    </item>
    <item>
      <title>GCP The Hard Way — Part 4: Choosing the Wrong NoSQL Database, and Migrating When It Shows</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:37:52 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/gcp-the-hard-way-part-4-choosing-the-wrong-nosql-database-and-migrating-when-it-shows-59nd</link>
      <guid>https://dev.to/pilgrim2go/gcp-the-hard-way-part-4-choosing-the-wrong-nosql-database-and-migrating-when-it-shows-59nd</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Database selection guidance often reads like a features checklist:&lt;br&gt;
Firestore for flexible documents and real-time sync, Bigtable for&lt;br&gt;
high-throughput analytical workloads. What that guidance doesn't&lt;br&gt;
always convey is &lt;em&gt;how&lt;/em&gt; the wrong choice manifests in practice — as&lt;br&gt;
cost growth, query limitations, or both. This post builds a&lt;br&gt;
high-frequency event-logging workload on Firestore, measures where it&lt;br&gt;
breaks down, and migrates the same workload to Bigtable, including the&lt;br&gt;
row-key design work that Bigtable migrations often get wrong on the&lt;br&gt;
first attempt.&lt;/p&gt;
&lt;h2&gt;
  
  
  Solution overview
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Phase A                          Phase B
┌──────────┐                    ┌──────────┐
│  Event    │   ~200 writes/s    │  Event    │   ~200 writes/s
│  Producer │──────────────────▶│  Producer │──────────────────▶
└──────────┘                    └──────────┘
     │                                │
     ▼                                ▼
┌──────────┐                    ┌──────────┐
│ Firestore │                    │ Bigtable  │
│ (documents)│                   │(wide-column)│
└──────────┘                    └──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Compute Engine VM to run the load-generation script (isolates
network variability from your local machine)&lt;/li&gt;
&lt;li&gt;Familiarity with basic Python&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Walkthrough
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Phase A: Firestore under sustained write load
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud services &lt;span class="nb"&gt;enable &lt;/span&gt;firestore.googleapis.com
gcloud firestore databases create &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1 &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;firestore-native
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;google.cloud&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;firestore&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;firestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;EVENT_TYPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;click&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;view&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;purchase&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;login&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;write_event&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;document&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&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;user_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EVENT_TYPES&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;firestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SERVER_TIMESTAMP&lt;/span&gt;&lt;span class="p"&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;worker&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;write_event&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Run this for 15–20 minutes to generate roughly 200,000 documents, then&lt;br&gt;
attempt an aggregate query:&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="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;==&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;click&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Observed limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-field queries frequently require a manually created composite
index, surfaced through an error message with an index-creation link&lt;/li&gt;
&lt;li&gt;Aggregate counts over large result sets incur a read operation per
document, which is directly reflected in Billing Reports under
Cloud Firestore&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Review actual cost in &lt;strong&gt;Billing → Reports&lt;/strong&gt;, filtered to Cloud&lt;br&gt;
Firestore, before proceeding to the migration.&lt;/p&gt;
&lt;h3&gt;
  
  
  Phase B: Migrating to Bigtable
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Row-key design is the primary design decision in this migration.&lt;/strong&gt;&lt;br&gt;
A row key derived directly from a monotonically increasing value (such&lt;br&gt;
as a raw timestamp) causes writes to concentrate on a single tablet —&lt;br&gt;
a pattern known as hotspotting — because Bigtable stores rows in&lt;br&gt;
lexicographic key order.&lt;/p&gt;

&lt;p&gt;A row key that leads with a high-cardinality field, such as&lt;br&gt;
&lt;code&gt;user_id&lt;/code&gt;, distributes writes across the keyspace:&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="n"&gt;row_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;#&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;reversed_timestamp&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;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;reversed_timestamp = 9999999999999 - unix_timestamp_ms&lt;/code&gt;,&lt;br&gt;
placing the most recent event first within a given user's key range.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud services &lt;span class="nb"&gt;enable &lt;/span&gt;bigtable.googleapis.com bigtableadmin.googleapis.com

gcloud bigtable instances create event-log-bt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;event-log-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-b &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-num-nodes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;PRODUCTION

cbt &lt;span class="nt"&gt;-instance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;event-log-bt createtable events
cbt &lt;span class="nt"&gt;-instance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;event-log-bt createfamily events cf1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;google.cloud&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;bigtable&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bigtable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;PROJECT_ID&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event-log-bt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events&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;write_event&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&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;user_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ts_ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;row_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;#&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;9999999999999&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ts_ms&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;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;direct_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_cell&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cf1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;click&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;view&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;purchase&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Validating key distribution:&lt;/strong&gt; Run the same load twice — once with a&lt;br&gt;
naive timestamp-only key, once with the &lt;code&gt;user_id&lt;/code&gt;-prefixed key — and&lt;br&gt;
compare per-node CPU utilization in &lt;strong&gt;Bigtable → Monitoring&lt;/strong&gt;. A&lt;br&gt;
poorly distributed key shows a pronounced skew toward a single node;&lt;br&gt;
a well-distributed key shows roughly even utilization across the&lt;br&gt;
cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost comparison
&lt;/h2&gt;

&lt;p&gt;Bigtable bills for provisioned node-hours regardless of traffic&lt;br&gt;
volume, while Firestore bills per operation. For short-duration,&lt;br&gt;
moderate-volume workloads like this walkthrough, Bigtable may appear&lt;br&gt;
&lt;em&gt;more&lt;/em&gt; expensive in absolute terms — Bigtable's cost efficiency&lt;br&gt;
advantage typically emerges only at sustained high throughput (commonly&lt;br&gt;
millions of operations per day). Record both figures from Billing&lt;br&gt;
Reports for a direct comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean up resources
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud bigtable instances delete event-log-bt &lt;span class="nt"&gt;--quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firestore data must be deleted at the collection level, or by removing&lt;br&gt;
the entire test project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Neither database is universally superior — the right choice depends on&lt;br&gt;
access patterns and scale, not feature checklists alone. This&lt;br&gt;
walkthrough demonstrates two concrete decision points worth&lt;br&gt;
internalizing: composite index requirements as an early signal that&lt;br&gt;
Firestore's query model may not fit the workload, and row-key design&lt;br&gt;
as the single highest-leverage decision in any Bigtable schema.&lt;/p&gt;

&lt;p&gt;In Part 5, we deploy to GKE and introduce a bug that only&lt;br&gt;
manifests after the container has already passed its initial health&lt;br&gt;
check.&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>firestore</category>
      <category>bigtable</category>
      <category>database</category>
    </item>
    <item>
      <title>GCP The Hard Way — Part 3: Auditing IAM Misconfigurations Before They Become Incidents</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:37:43 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/gcp-the-hard-way-part-3-auditing-iam-misconfigurations-before-they-become-incidents-1g3</link>
      <guid>https://dev.to/pilgrim2go/gcp-the-hard-way-part-3-auditing-iam-misconfigurations-before-they-become-incidents-1g3</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;IAM misconfigurations rarely announce themselves. An over-permissioned&lt;br&gt;
service account or a publicly readable storage bucket typically&lt;br&gt;
continues functioning without error, which is precisely what makes&lt;br&gt;
these issues dangerous — they persist silently until discovered by an&lt;br&gt;
audit, or by an attacker. This post walks through introducing three&lt;br&gt;
realistic IAM misconfigurations into a test project, then performing a&lt;br&gt;
structured security review to identify and remediate them using native&lt;br&gt;
GCP tooling.&lt;/p&gt;
&lt;h2&gt;
  
  
  Solution overview
&lt;/h2&gt;

&lt;p&gt;We separate this exercise into two distinct phases to preserve the&lt;br&gt;
integrity of the audit: a configuration phase where common&lt;br&gt;
misconfigurations are introduced, and a review phase performed without&lt;br&gt;
reference to the configuration phase, using only Cloud IAM and Cloud&lt;br&gt;
Asset Inventory tooling.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌───────────────────┐        ┌────────────────────┐
│  Configuration      │        │      Review          │
│  Phase               │──────▶│      Phase            │
│                      │ 15-30 │                      │
│  - Broad IAM role    │  min  │  - IAM policy audit   │
│  - Public bucket     │ gap   │  - Asset Inventory    │
│  - Exported SA key   │       │  - Key rotation       │
└───────────────────┘        └────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A dedicated test project (do not perform this exercise against
production infrastructure)&lt;/li&gt;
&lt;li&gt;The Cloud Asset API enabled: &lt;code&gt;gcloud services enable cloudasset.googleapis.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Walkthrough
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Phase 1: Introduce common misconfigurations
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Overly permissive service account role:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud iam service-accounts create sloppy-sa &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--display-name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Sloppy Dev SA"&lt;/span&gt;

gcloud projects add-iam-policy-binding &amp;lt;PROJECT_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"serviceAccount:sloppy-sa@&amp;lt;PROJECT_ID&amp;gt;.iam.gserviceaccount.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/editor"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Publicly readable storage bucket:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gsutil mb &lt;span class="nt"&gt;-l&lt;/span&gt; asia-southeast1 gs://&amp;lt;PROJECT_ID&amp;gt;-sloppy-bucket
gsutil iam ch allUsers:objectViewer gs://&amp;lt;PROJECT_ID&amp;gt;-sloppy-bucket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Exported service account key:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud iam service-accounts keys create sloppy-key.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--iam-account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sloppy-sa@&amp;lt;PROJECT_ID&amp;gt;.iam.gserviceaccount.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy a minimal Cloud Run service using this service account to&lt;br&gt;
simulate a realistic dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run deploy sloppy-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gcr.io/cloudrun/hello &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sloppy-sa@&amp;lt;PROJECT_ID&amp;gt;.iam.gserviceaccount.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1 &lt;span class="nt"&gt;--allow-unauthenticated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Phase 2: Perform the security review
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Identify over-permissioned identities:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud projects get-iam-policy &amp;lt;PROJECT_ID&amp;gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json

gcloud asset search-all-iam-policies &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;projects/&amp;lt;PROJECT_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"policy:roles/editor"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any service account bound to &lt;code&gt;roles/editor&lt;/code&gt; or &lt;code&gt;roles/owner&lt;/code&gt; warrants&lt;br&gt;
scrutiny — these broad roles are rarely required for a well-scoped&lt;br&gt;
workload identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identify publicly accessible storage:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gsutil iam get gs://&amp;lt;PROJECT_ID&amp;gt;-sloppy-bucket

gcloud asset search-all-iam-policies &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;projects/&amp;lt;PROJECT_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"policy:allUsers"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Identify exported credentials:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud iam service-accounts keys list &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--iam-account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sloppy-sa@&amp;lt;PROJECT_ID&amp;gt;.iam.gserviceaccount.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Phase 3: Remediate
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud projects remove-iam-policy-binding &amp;lt;PROJECT_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"serviceAccount:sloppy-sa@&amp;lt;PROJECT_ID&amp;gt;.iam.gserviceaccount.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/editor"&lt;/span&gt;

gcloud projects add-iam-policy-binding &amp;lt;PROJECT_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"serviceAccount:sloppy-sa@&amp;lt;PROJECT_ID&amp;gt;.iam.gserviceaccount.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/storage.objectViewer"&lt;/span&gt;

gsutil iam ch &lt;span class="nt"&gt;-d&lt;/span&gt; allUsers:objectViewer gs://&amp;lt;PROJECT_ID&amp;gt;-sloppy-bucket

gcloud iam service-accounts keys delete &amp;lt;KEY_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--iam-account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sloppy-sa@&amp;lt;PROJECT_ID&amp;gt;.iam.gserviceaccount.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redeploy or invoke the Cloud Run service to confirm it continues to&lt;br&gt;
function correctly under the narrower &lt;code&gt;storage.objectViewer&lt;/code&gt; role.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Design consideration&lt;/strong&gt;: most GCP-managed compute services (Cloud&lt;br&gt;
Run, GKE, Compute Engine) can use a service account's identity&lt;br&gt;
directly without an exported JSON key. Keys should generally only be&lt;br&gt;
created for workloads running outside Google Cloud — unnecessary key&lt;br&gt;
exports are a frequent root cause of credential leakage incidents.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Clean up resources
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run services delete sloppy-service &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1 &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gsutil &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; gs://&amp;lt;PROJECT_ID&amp;gt;-sloppy-bucket
gcloud iam service-accounts delete &lt;span class="se"&gt;\&lt;/span&gt;
  sloppy-sa@&amp;lt;PROJECT_ID&amp;gt;.iam.gserviceaccount.com &lt;span class="nt"&gt;--quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This exercise illustrates why IAM review needs to be a routine,&lt;br&gt;
tool-assisted process rather than a one-time configuration step.&lt;br&gt;
&lt;code&gt;gcloud asset search-all-iam-policies&lt;/code&gt; in particular scales to&lt;br&gt;
project- and organization-wide queries that manual policy inspection&lt;br&gt;
cannot practically match.&lt;/p&gt;

&lt;p&gt;In Part 4, we turn to data modeling — deliberately selecting the&lt;br&gt;
wrong database for a workload, and migrating once the cost and&lt;br&gt;
performance implications become apparent.&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>iam</category>
      <category>security</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>GCP The Hard Way — Part 2: Diagnosing Failures in a Highly Available Three-Tier Architecture</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:36:13 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/gcp-the-hard-way-part-2-diagnosing-failures-in-a-highly-available-three-tier-architecture-57n1</link>
      <guid>https://dev.to/pilgrim2go/gcp-the-hard-way-part-2-diagnosing-failures-in-a-highly-available-three-tier-architecture-57n1</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;High availability features like managed instance groups (MIGs) and&lt;br&gt;
load balancing are often treated as "set and forget" — configure them&lt;br&gt;
once, and the platform handles the rest. In practice, understanding&lt;br&gt;
&lt;em&gt;when&lt;/em&gt; and &lt;em&gt;how quickly&lt;/em&gt; these mechanisms respond to failure is&lt;br&gt;
essential for setting realistic availability expectations. This post&lt;br&gt;
walks through provisioning a three-tier architecture on Google Cloud&lt;br&gt;
and deliberately introducing five distinct failure conditions to&lt;br&gt;
observe and measure the platform's actual recovery behavior.&lt;/p&gt;
&lt;h2&gt;
  
  
  Solution overview
&lt;/h2&gt;

&lt;p&gt;The architecture consists of a global HTTP load balancer distributing&lt;br&gt;
traffic across a managed instance group, backed by a health check that&lt;br&gt;
determines instance eligibility for traffic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        ┌───────────────────────┐
Client ─▶  Global HTTP(S) LB     │
        └───────────┬───────────┘
                     │
        ┌────────────▼────────────┐
        │   Backend Service         │
        │   (Health Check: /health) │
        └────────────┬────────────┘
                     │
     ┌───────────────┼───────────────┐
     ▼                                ▼
┌─────────┐                    ┌─────────┐
│  VM 1   │                    │  VM 2   │
└─────────┘                    └─────────┘
   Managed Instance Group (min:2, max:4)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Completion of Part 1, or equivalent familiarity with Compute Engine
and firewall rules&lt;/li&gt;
&lt;li&gt;A load-testing tool such as &lt;code&gt;hey&lt;/code&gt; or Apache Bench installed locally
or on a separate VM&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Walkthrough
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Deploy the instance template and managed instance group
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instance-templates create app-template &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;e2-small &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-12 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-cloud &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-server &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metadata-from-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;startup-script&lt;span class="o"&gt;=&lt;/span&gt;startup.sh

gcloud compute instance-groups managed create app-mig &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--template&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-template &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a

gcloud compute instance-groups managed set-autoscaling app-mig &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-num-replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="nt"&gt;--min-num-replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-cpu-utilization&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.6 &lt;span class="nt"&gt;--cool-down-period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Configure health checks and the load balancer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute health-checks create http app-health-check &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8080 &lt;span class="nt"&gt;--request-path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/health

gcloud compute firewall-rules create allow-health-check &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tcp:8080 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-ranges&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;130.211.0.0/22,35.191.0.0/16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-server

gcloud compute backend-services create app-backend &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--protocol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;HTTP &lt;span class="nt"&gt;--port-name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--health-checks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-health-check &lt;span class="nt"&gt;--global&lt;/span&gt;

gcloud compute backend-services add-backend app-backend &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-mig &lt;span class="nt"&gt;--instance-group-zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--global&lt;/span&gt;

gcloud compute url-maps create app-lb &lt;span class="nt"&gt;--default-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-backend
gcloud compute target-http-proxies create app-http-proxy &lt;span class="nt"&gt;--url-map&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-lb
gcloud compute forwarding-rules create app-http-rule &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--global&lt;/span&gt; &lt;span class="nt"&gt;--target-http-proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-http-proxy &lt;span class="nt"&gt;--ports&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Allow 5–10 minutes for the backend to transition to a &lt;code&gt;HEALTHY&lt;/code&gt; state&lt;br&gt;
before proceeding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure scenario 1: Manual instance termination
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instance-groups managed list-instances app-mig &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a
gcloud compute instances delete &amp;lt;INSTANCE_NAME&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Observation:&lt;/strong&gt; Measure the time between deletion and the appearance&lt;br&gt;
of a replacement instance. If no replacement appears within several&lt;br&gt;
minutes, verify whether autohealing is configured — MIG autoscaling&lt;br&gt;
and autohealing are independent mechanisms, and autoscaling alone does&lt;br&gt;
not guarantee replacement of a manually terminated instance outside of&lt;br&gt;
its target size logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure scenario 2: Health check path blocked by firewall
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute firewall-rules delete allow-health-check &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud compute backend-services get-health app-backend &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Diagnostic approach:&lt;/strong&gt; From inside an affected VM, run&lt;br&gt;
&lt;code&gt;curl localhost:8080/health&lt;/code&gt; directly. A successful local response&lt;br&gt;
combined with a failing &lt;code&gt;get-health&lt;/code&gt; status isolates the fault to the&lt;br&gt;
network path rather than the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure scenario 3: Load-induced autoscaling
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hey &lt;span class="nt"&gt;-z&lt;/span&gt; 5m &lt;span class="nt"&gt;-c&lt;/span&gt; 200 http://&amp;lt;LB_IP&amp;gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Observation:&lt;/strong&gt; Track the interval between CPU utilization crossing&lt;br&gt;
the 60% threshold and new instances becoming &lt;code&gt;HEALTHY&lt;/code&gt; and receiving&lt;br&gt;
traffic. Expect a multi-minute delay attributable to three sequential&lt;br&gt;
stages: autoscaler metric evaluation, VM boot and startup script&lt;br&gt;
execution, and health check confirmation before the load balancer&lt;br&gt;
begins routing traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure scenario 4: Misconfigured health check path
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute health-checks update http app-health-check &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--request-path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/healthz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Diagnostic approach:&lt;/strong&gt; This produces the same &lt;code&gt;UNHEALTHY&lt;/code&gt; status as&lt;br&gt;
Scenario 2 but stems from an application-layer mismatch rather than a&lt;br&gt;
network block — reinforcing that identical symptoms can have distinct&lt;br&gt;
root causes, and log inspection (not assumption) should drive&lt;br&gt;
remediation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure scenario 5: Full instance group depletion
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instance-groups managed resize app-mig &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restore the health check path and resize back to 2, and measure total&lt;br&gt;
recovery time from zero running instances to a healthy backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean up resources
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute forwarding-rules delete app-http-rule &lt;span class="nt"&gt;--global&lt;/span&gt; &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud compute target-http-proxies delete app-http-proxy &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud compute url-maps delete app-lb &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud compute backend-services delete app-backend &lt;span class="nt"&gt;--global&lt;/span&gt; &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud compute instance-groups managed delete app-mig &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud compute instance-templates delete app-template &lt;span class="nt"&gt;--quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Each of these five scenarios produces a distinct combination of&lt;br&gt;
symptoms and recovery timelines, despite relying on the same&lt;br&gt;
underlying platform features. The key takeaway is that autoscaling,&lt;br&gt;
autohealing, and load balancer health checking are separate,&lt;br&gt;
composable mechanisms — understanding their individual behavior is&lt;br&gt;
what separates confident incident response from guesswork.&lt;/p&gt;

&lt;p&gt;In Part 3, we shift focus to IAM, auditing a project for&lt;br&gt;
privilege escalation risks introduced through common configuration&lt;br&gt;
shortcuts.&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>loadbalancing</category>
      <category>sre</category>
      <category>devops</category>
    </item>
    <item>
      <title>GCP The Hard Way — Part 1: Migrating a Legacy Application Without Touching Its Code</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:36:06 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/gcp-the-hard-way-part-1-migrating-a-legacy-application-without-touching-its-code-52d3</link>
      <guid>https://dev.to/pilgrim2go/gcp-the-hard-way-part-1-migrating-a-legacy-application-without-touching-its-code-52d3</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A common first step in cloud adoption is the "lift-and-shift" migration:&lt;br&gt;
moving an existing application to the cloud with minimal changes to&lt;br&gt;
reduce migration risk and preserve the original release cadence. In&lt;br&gt;
this post, we walk through migrating a application with a hardcoded&lt;br&gt;
&lt;code&gt;localhost&lt;/code&gt; database dependency to Google Cloud, without modifying a&lt;br&gt;
single line of application code — relying instead on infrastructure&lt;br&gt;
and configuration to bridge the gap.&lt;/p&gt;

&lt;p&gt;By the end of this walkthrough, you will have deployed a Compute Engine&lt;br&gt;
instance connected to a Cloud SQL database over a private network path,&lt;br&gt;
with no public database endpoint exposed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Solution overview
&lt;/h2&gt;

&lt;p&gt;The target architecture places the application on a Compute Engine VM&lt;br&gt;
and the database on Cloud SQL, connected through the Cloud SQL Auth&lt;br&gt;
Proxy running as a sidecar process on the VM. This preserves the&lt;br&gt;
application's original &lt;code&gt;localhost&lt;/code&gt; connection string while routing&lt;br&gt;
traffic securely to the managed database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────┐
│      Compute Engine VM       │
│  ┌────────┐   ┌───────────┐  │        ┌───────────────┐
│  │  App   │──▶│ Cloud SQL │──┼───────▶│   Cloud SQL    │
│  │        │   │Auth Proxy │  │Private │ (Private IP)   │
│  └────────┘   └───────────┘  │  VPC   └───────────────┘
└─────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A GCP project with the Compute Engine and Cloud SQL Admin APIs enabled&lt;/li&gt;
&lt;li&gt;A sample application (Node.js, Flask, or WordPress) that reads its
database host from a config file or environment variable set to
&lt;code&gt;localhost&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The application verified working in a local environment before
migration begins&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Walkthrough
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Provision the database and compute resources
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud services &lt;span class="nb"&gt;enable &lt;/span&gt;compute.googleapis.com sqladmin.googleapis.com

gcloud sql instances create app-db &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--database-version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;MYSQL_8_0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;db-f1-micro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-assign-ip&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default

gcloud sql databases create appdb &lt;span class="nt"&gt;--instance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-db

gcloud compute instances create app-vm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;e2-small &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-12 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-cloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We disable the public IP on the Cloud SQL instance from the start&lt;br&gt;
(&lt;code&gt;--no-assign-ip&lt;/code&gt;), reflecting a common enterprise security requirement&lt;br&gt;
that databases never be reachable over the public internet.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 2: Establish private connectivity
&lt;/h3&gt;

&lt;p&gt;Cloud SQL private IP requires a VPC peering connection to Google's&lt;br&gt;
managed services range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute addresses create google-managed-services-default &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--global&lt;/span&gt; &lt;span class="nt"&gt;--purpose&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;VPC_PEERING &lt;span class="nt"&gt;--prefix-length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;16 &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default

gcloud services vpc-peerings connect &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;servicenetworking.googleapis.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ranges&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;google-managed-services-default &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Deploy the Cloud SQL Auth Proxy on the VM
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute ssh app-vm &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the VM:&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;-o&lt;/span&gt; cloud-sql-proxy &lt;span class="se"&gt;\&lt;/span&gt;
  https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.11.0/cloud-sql-proxy.linux.amd64
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x cloud-sql-proxy
./cloud-sql-proxy &lt;span class="nt"&gt;--private-ip&lt;/span&gt; &amp;lt;PROJECT_ID&amp;gt;:asia-southeast1:app-db &amp;amp;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proxy binds to &lt;code&gt;127.0.0.1:3306&lt;/code&gt; by default, which is exactly the&lt;br&gt;
endpoint the application already expects.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 4: Open the required firewall path
&lt;/h3&gt;

&lt;p&gt;Applications frequently listen on ports other than 80/443. Configure&lt;br&gt;
the firewall to allow only the specific port the application requires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances add-tags app-vm &lt;span class="nt"&gt;--tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-server &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a

gcloud compute firewall-rules create allow-app-port &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tcp:3000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-ranges&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.0.0.0/0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Common pitfall&lt;/strong&gt;: a connection timeout at this stage can originate&lt;br&gt;
from either a missing firewall rule or an application that binds to&lt;br&gt;
&lt;code&gt;127.0.0.1&lt;/code&gt; instead of &lt;code&gt;0.0.0.0&lt;/code&gt;. Verify with &lt;code&gt;curl localhost:3000&lt;/code&gt;&lt;br&gt;
from inside the VM before troubleshooting the network path.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 5: Attach a custom domain
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud dns managed-zones create app-zone &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dns-name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"yourdomain.com."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Lift and shift walkthrough"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create an &lt;code&gt;A&lt;/code&gt; record pointing to the VM's external IP through the&lt;br&gt;
Cloud Console or &lt;code&gt;gcloud dns record-sets create&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating the deployment
&lt;/h2&gt;

&lt;p&gt;Confirm the following before considering the migration complete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gcloud sql instances describe app-db&lt;/code&gt; shows no public IP address&lt;/li&gt;
&lt;li&gt;The application is reachable via the custom domain, not the raw IP&lt;/li&gt;
&lt;li&gt;No port other than 80/443 is exposed to &lt;code&gt;0.0.0.0/0&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Clean up resources
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances delete app-vm &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-southeast1-a &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud sql instances delete app-db &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud compute firewall-rules delete allow-app-port &lt;span class="nt"&gt;--quiet&lt;/span&gt;
gcloud dns managed-zones delete app-zone &lt;span class="nt"&gt;--quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This walkthrough demonstrated that a lift-and-shift migration is&lt;br&gt;
rarely "zero infrastructure change" even when it is "zero code change."&lt;br&gt;
The Cloud SQL Auth Proxy pattern shown here is a common bridge for&lt;br&gt;
applications that cannot be immediately refactored to use&lt;br&gt;
cloud-native service discovery.&lt;/p&gt;

&lt;p&gt;In Part 2, we build on this foundation with a highly available&lt;br&gt;
three-tier architecture — and deliberately break it five different&lt;br&gt;
ways to practice production diagnostics.&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>computeengine</category>
      <category>cloudsql</category>
      <category>devops</category>
    </item>
    <item>
      <title>Google Cloud Fundamentals — The Hard Way: A Break-and-Fix Learning Series</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:22:08 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/gcp-the-hard-way-part-1-lift-and-shift-without-touching-the-code-4mhm</link>
      <guid>https://dev.to/pilgrim2go/gcp-the-hard-way-part-1-lift-and-shift-without-touching-the-code-4mhm</guid>
      <description>&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;p&gt;Most cloud onboarding tutorials optimize for one outcome: getting a&lt;br&gt;
resource into a &lt;code&gt;RUNNING&lt;/code&gt; state as fast as possible. That approach&lt;br&gt;
teaches syntax, but it doesn't teach the skill that actually matters in&lt;br&gt;
production — diagnosing a system when it's &lt;em&gt;not&lt;/em&gt; behaving as expected,&lt;br&gt;
using only logs, metrics, and a hypothesis-driven process of&lt;br&gt;
elimination.&lt;/p&gt;

&lt;p&gt;This eight-part series takes the opposite approach. Each post walks&lt;br&gt;
through provisioning a piece of Google Cloud infrastructure using the&lt;br&gt;
&lt;code&gt;gcloud&lt;/code&gt; CLI, deliberately introducing a realistic failure mode into&lt;br&gt;
that infrastructure, and then walking through the diagnostic process&lt;br&gt;
needed to identify and remediate the issue — with the actual commands,&lt;br&gt;
log queries, and decision points included.&lt;/p&gt;

&lt;p&gt;This series is intended for engineers who already understand IaaS/PaaS&lt;br&gt;
fundamentals and want hands-on, failure-driven practice with core&lt;br&gt;
GCP services, in the spirit of &lt;a href="https://github.com/kelseyhightower/kubernetes-the-hard-way" rel="noopener noreferrer"&gt;Kubernetes The Hard Way&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  What this series covers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Focus area&lt;/th&gt;
&lt;th&gt;GCP services&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Migrating a legacy application without modifying its source&lt;/td&gt;
&lt;td&gt;Compute Engine, Cloud SQL, Cloud SQL Auth Proxy, VPC Firewall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Diagnosing failures in a highly available three-tier architecture&lt;/td&gt;
&lt;td&gt;Managed Instance Groups, Cloud Load Balancing, Health Checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Auditing and remediating IAM misconfigurations&lt;/td&gt;
&lt;td&gt;IAM, Service Accounts, Cloud Asset Inventory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Choosing (and migrating between) NoSQL data stores&lt;/td&gt;
&lt;td&gt;Firestore, Bigtable, row-key design&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Safe rollout patterns for containerized workloads&lt;/td&gt;
&lt;td&gt;GKE, liveness/readiness probes, canary deployment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Validating disaster recovery assumptions&lt;/td&gt;
&lt;td&gt;Cross-region Cloud SQL replicas, Cloud DNS, RTO/RPO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Identifying and eliminating cloud waste&lt;/td&gt;
&lt;td&gt;Billing Reports, Recommender, FinOps fundamentals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Designing failure scenarios for team training&lt;/td&gt;
&lt;td&gt;Incident design, SRE practices&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Architecture pattern used throughout the series
&lt;/h2&gt;

&lt;p&gt;Several posts in this series build on a common reference architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    ┌────────────────────┐
   Client  ──────▶  │ Cloud Load Balancer │
                    └─────────┬──────────┘
                              │
                    ┌─────────▼──────────┐
                    │  Managed Instance    │
                    │  Group (2–4 VMs)     │
                    └─────────┬──────────┘
                              │  Private IP
                    ┌─────────▼──────────┐
                    │     Cloud SQL       │
                    │  (Primary + Replica)│
                    └────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern is intentionally simple — the goal isn't architectural&lt;br&gt;
complexity, it's giving you a stable baseline you can break in&lt;br&gt;
controlled, repeatable ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites for the series
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Google Cloud project with billing enabled (a fresh project with
free trial credit or a dedicated test project is strongly recommended)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;gcloud&lt;/code&gt; CLI installed locally, or access to Cloud Shell&lt;/li&gt;
&lt;li&gt;Working familiarity with Linux command-line tools and basic log
interpretation&lt;/li&gt;
&lt;li&gt;A Budget Alert configured on your billing account (instructions in
Part 1) to avoid unexpected cost overrun while experimenting&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to use this series
&lt;/h2&gt;

&lt;p&gt;Each post is self-contained but builds conceptually on the ones before&lt;br&gt;
it — Part 8 in particular reuses techniques introduced in Parts 2–4.&lt;br&gt;
We recommend working through them in order, and resisting the urge to&lt;br&gt;
look up the fix for an injected failure for at least 20–30 minutes.&lt;br&gt;
That struggle is where the retained learning happens.&lt;/p&gt;

&lt;p&gt;Let's get started with [Part 1: Migrating a Legacy Application Without&lt;br&gt;
Touching Its Code →]&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>cloud</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Tutorial: Role-Based Access Control for a Node/Express API, Backed by Keycloak</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Tue, 21 Jul 2026 03:00:20 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/tutorial-role-based-access-control-for-a-nodeexpress-api-backed-by-keycloak-15h3</link>
      <guid>https://dev.to/pilgrim2go/tutorial-role-based-access-control-for-a-nodeexpress-api-backed-by-keycloak-15h3</guid>
      <description>&lt;p&gt;This is a hands-on walkthrough for &lt;strong&gt;kc-rbac-api&lt;/strong&gt; — module 04 in a series&lt;br&gt;
building up Keycloak features one small, runnable project at a time. By the&lt;br&gt;
end you'll have a Node/Express API with three roles enforced at the&lt;br&gt;
endpoint level, two interchangeable ways of validating incoming tokens, and&lt;br&gt;
a full test suite proving the denials work as intended, not just the happy&lt;br&gt;
path.&lt;/p&gt;

&lt;p&gt;Everything below assumes Docker + Docker Compose, Node.js, &lt;code&gt;curl&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;python3&lt;/code&gt; (used by the setup script for JSON parsing — nothing else).&lt;/p&gt;
&lt;h2&gt;
  
  
  What you're building
&lt;/h2&gt;

&lt;p&gt;An API with three roles — &lt;code&gt;viewer&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt; — where each endpoint&lt;br&gt;
enforces a specific minimum role, not just "logged in or not":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;viewer&lt;/th&gt;
&lt;th&gt;editor&lt;/th&gt;
&lt;th&gt;admin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST /api/documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PUT /api/documents/:id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DELETE /api/documents/:id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/admin/stats&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/whoami&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice &lt;code&gt;DELETE&lt;/code&gt; breaks the simple "editor and up" pattern — it's&lt;br&gt;
admin-only. That's deliberate, and it's the detail worth testing carefully&lt;br&gt;
later on.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1 — Start Keycloak and the API
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make start   &lt;span class="c"&gt;# docker compose up -d --build: Keycloak + this API's container&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Give Keycloak about 30 seconds to finish booting, then provision everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make setup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs &lt;code&gt;keycloak/setup.sh&lt;/code&gt;, which — entirely through Keycloak's Admin&lt;br&gt;
REST API, no clicking through the console — creates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a realm called &lt;code&gt;rbac-demo&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;three realm roles: &lt;code&gt;viewer&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a confidential client &lt;code&gt;kc-rbac-api&lt;/code&gt; with an audience mapper (more on that
below)&lt;/li&gt;
&lt;li&gt;three demo users, one per role, and writes the client secret into &lt;code&gt;.env&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Username&lt;/th&gt;
&lt;th&gt;Password&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;alice&lt;/td&gt;
&lt;td&gt;alice&lt;/td&gt;
&lt;td&gt;viewer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bob&lt;/td&gt;
&lt;td&gt;bob&lt;/td&gt;
&lt;td&gt;editor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;carol&lt;/td&gt;
&lt;td&gt;carol&lt;/td&gt;
&lt;td&gt;admin&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Confirm the API is up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:3000/health
&lt;span class="c"&gt;# {"status":"ok","validationMode":"local"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2 — Understand the two ways to validate a token
&lt;/h2&gt;

&lt;p&gt;Every protected route sits behind one of two middlewares, and which one is&lt;br&gt;
active is a config toggle (&lt;code&gt;TOKEN_VALIDATION_MODE&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;) — not a code&lt;br&gt;
change:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;src/middleware/jwtAuth.js&lt;/code&gt; (&lt;code&gt;local&lt;/code&gt;)&lt;/strong&gt; verifies the token's RS256&lt;br&gt;
signature locally, against Keycloak's JWKS endpoint (cached via&lt;br&gt;
&lt;code&gt;jwks-rsa&lt;/code&gt;), then checks &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;aud&lt;/code&gt;, and &lt;code&gt;exp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getSigningKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RS256&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&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;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fast — one JWKS fetch, then it's pure CPU per request after that. The&lt;br&gt;
tradeoff: if Keycloak revokes the token or the user logs out server-side,&lt;br&gt;
this middleware has no way to know. The token stays "valid" here until it&lt;br&gt;
naturally expires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;src/middleware/introspectAuth.js&lt;/code&gt; (&lt;code&gt;introspect&lt;/code&gt;)&lt;/strong&gt; instead POSTs the&lt;br&gt;
token to Keycloak's &lt;code&gt;/token/introspect&lt;/code&gt; endpoint (RFC 7662) on every&lt;br&gt;
request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;introspectionUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/x-www-form-urlencoded&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token_inactive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slower — one extra network round trip per call — but revocation is&lt;br&gt;
immediate: Keycloak reports &lt;code&gt;active: false&lt;/code&gt; the moment a token is no&lt;br&gt;
longer valid, regardless of its &lt;code&gt;exp&lt;/code&gt;. This mode needs its own client&lt;br&gt;
credentials, since introspection is itself an authenticated endpoint —&lt;br&gt;
only clients that own or trust the token's audience get to ask Keycloak&lt;br&gt;
about it.&lt;/p&gt;

&lt;p&gt;Neither one is "the right answer" on its own — it's a latency-vs-freshness&lt;br&gt;
tradeoff. Short access-token lifespans (this realm is set to 300 seconds)&lt;br&gt;
are the usual way to bound local validation's blind spot without paying&lt;br&gt;
the introspection cost on every single call.&lt;/p&gt;

&lt;p&gt;Try switching modes and watching &lt;code&gt;/api/whoami&lt;/code&gt; reflect it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN_VALIDATION_MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;introspect npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/realms/rbac-demo/protocol/openid-connect/token &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=password&amp;amp;client_id=kc-rbac-api&amp;amp;client_secret=&lt;/span&gt;&lt;span class="nv"&gt;$CLIENT_SECRET&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;username=carol&amp;amp;password=carol"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import sys,json;print(json.load(sys.stdin)['access_token'])"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

curl http://localhost:3000/api/whoami &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# "authMode": "introspect"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3 — Enforce roles with two middleware shapes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;src/middleware/rbac.js&lt;/code&gt; gives you two ways to gate a route, and picking&lt;br&gt;
the right one matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Exact allow-list — use when roles AREN'T hierarchical for this endpoint&lt;/span&gt;
&lt;span class="nf"&gt;requireRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Hierarchical — viewer(1) &amp;lt; editor(2) &amp;lt; admin(3)&lt;/span&gt;
&lt;span class="nf"&gt;requireMinRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requireMinRole&lt;/code&gt; is what powers most of the table above — &lt;code&gt;POST&lt;/code&gt;/&lt;code&gt;PUT&lt;/code&gt;&lt;br&gt;
use &lt;code&gt;requireMinRole("editor")&lt;/code&gt; since editor-and-up should both succeed. But&lt;br&gt;
&lt;code&gt;DELETE&lt;/code&gt; uses &lt;code&gt;requireRole("admin")&lt;/code&gt; explicitly, &lt;em&gt;not&lt;/em&gt;&lt;br&gt;
&lt;code&gt;requireMinRole("admin")&lt;/code&gt; treated as "the top of the chain" by accident —&lt;br&gt;
it's named as an exact requirement on purpose. If you ever add a role that&lt;br&gt;
doesn't nest cleanly into the hierarchy (say, an &lt;code&gt;auditor&lt;/code&gt; who can read&lt;br&gt;
things &lt;code&gt;editor&lt;/code&gt; can't but can't write anything), model it with&lt;br&gt;
&lt;code&gt;requireRole()&lt;/code&gt; allow-lists rather than forcing it into the rank table.&lt;/p&gt;

&lt;p&gt;Roles themselves come out of the token via &lt;code&gt;src/utils/tokenClaims.js&lt;/code&gt;,&lt;br&gt;
which merges Keycloak's &lt;code&gt;realm_access.roles&lt;/code&gt; (realm-wide roles) and&lt;br&gt;
&lt;code&gt;resource_access.&amp;lt;clientId&amp;gt;.roles&lt;/code&gt; (client-scoped roles) into one flat set&lt;br&gt;
— route middleware doesn't care which bucket a role happened to live in.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4 — Understand why the audience (&lt;code&gt;aud&lt;/code&gt;) check exists
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;keycloak/setup.sh&lt;/code&gt; adds an &lt;code&gt;oidc-audience-mapper&lt;/code&gt; so tokens for this&lt;br&gt;
client carry &lt;code&gt;aud: kc-rbac-api&lt;/code&gt;, and both middlewares reject anything&lt;br&gt;
where that's missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;aud&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid_audience&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this check, a token minted for a &lt;em&gt;different&lt;/em&gt; API in the same&lt;br&gt;
realm — one with broader service-account scope, say — would pass&lt;br&gt;
signature and issuer checks just fine and get treated as valid here too.&lt;br&gt;
That's the "confused deputy" problem: a token issued for one purpose&lt;br&gt;
getting replayed against a resource server it was never meant for. &lt;code&gt;aud&lt;/code&gt;&lt;br&gt;
is the check that closes that gap.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 5 — Run the full allow/deny matrix
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This runs &lt;code&gt;tests/test.sh&lt;/code&gt;, a self-contained bash suite: it fetches real&lt;br&gt;
tokens for alice/bob/carol, hits every endpoint as every role, and asserts&lt;br&gt;
both the HTTP status &lt;em&gt;and&lt;/em&gt; the JSON error code — so a 403 for the wrong&lt;br&gt;
reason still fails the test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;── 6. DELETE /api/documents/:id (admin only) ──
✓ PASS  [DENY] viewer cannot delete   (got 403, error=insufficient_role)
✓ PASS  [DENY] editor cannot delete   (got 403, error=insufficient_role)
✓ PASS  [ALLOW] admin can delete      (got 204)

════════════════════════════════════════════════
  Results: 25 passed, 0 failed
════════════════════════════════════════════════
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's mode-aware: one assertion checks for &lt;code&gt;invalid_token&lt;/code&gt; under local&lt;br&gt;
validation but &lt;code&gt;token_inactive&lt;/code&gt; under introspection, since Keycloak's&lt;br&gt;
introspection endpoint doesn't distinguish "malformed" from&lt;br&gt;
"merely-unrecognized" — both are a correct 401, just different&lt;br&gt;
vocabulary. Exits non-zero on any failure, so it's safe to drop straight&lt;br&gt;
into CI.&lt;/p&gt;

&lt;p&gt;Prefer clicking through requests instead of reading a script? The same&lt;br&gt;
matrix is in &lt;code&gt;http/requests.http&lt;/code&gt; (VS Code REST Client — token requests&lt;br&gt;
save into named variables the later requests reuse automatically) and as&lt;br&gt;
a Postman collection + environment in &lt;code&gt;postman/&lt;/code&gt;, with &lt;code&gt;pm.test&lt;/code&gt;&lt;br&gt;
assertions on every request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Poke around the Keycloak Admin Console
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Username: &lt;code&gt;admin&lt;/code&gt; / Password: &lt;code&gt;admin&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Realm: &lt;code&gt;rbac-demo&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Client &lt;code&gt;kc-rbac-api&lt;/code&gt; → &lt;strong&gt;Roles&lt;/strong&gt; tab has &lt;code&gt;viewer&lt;/code&gt;/&lt;code&gt;editor&lt;/code&gt;/&lt;code&gt;admin&lt;/code&gt; →
&lt;strong&gt;Client scopes&lt;/strong&gt; → &lt;strong&gt;Dedicated scopes&lt;/strong&gt; → &lt;strong&gt;Mappers&lt;/strong&gt; has the audience
mapper you just saw enforced in code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worth clicking into a user (say, &lt;code&gt;carol&lt;/code&gt;) and looking at &lt;strong&gt;Role mapping&lt;/strong&gt;&lt;br&gt;
to see the realm role assignment that ends up in &lt;code&gt;realm_access.roles&lt;/code&gt; on&lt;br&gt;
her token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you take this to production
&lt;/h2&gt;

&lt;p&gt;A few deliberate simplifications worth knowing about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This demo uses Resource Owner Password Credentials&lt;/strong&gt; (&lt;code&gt;grant_type=password&lt;/code&gt;)
purely so &lt;code&gt;setup.sh&lt;/code&gt;, the &lt;code&gt;.http&lt;/code&gt; file, and Postman can fetch tokens with
one curl call per user. Browser-facing apps should use &lt;strong&gt;Authorization
Code + PKCE&lt;/strong&gt; instead — keep &lt;code&gt;directAccessGrantsEnabled: false&lt;/code&gt; for those
clients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The in-memory &lt;code&gt;documents&lt;/code&gt; array resets on restart.&lt;/strong&gt; Swap it for a real
datastore before this goes anywhere near real traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;requireMinRole&lt;/code&gt; assumes a strictly linear hierarchy.&lt;/strong&gt; It works great
for &lt;code&gt;viewer &amp;lt; editor &amp;lt; admin&lt;/code&gt;, but don't force a non-nesting role into it
— use &lt;code&gt;requireRole()&lt;/code&gt; allow-lists instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the pieces live
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;keycloak/setup.sh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Realm, roles, client + audience mapper, 3 demo users — all via Admin REST API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/middleware/jwtAuth.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Local JWT validation (JWKS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/middleware/introspectAuth.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;RFC 7662 introspection validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/middleware/rbac.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;requireRole()&lt;/code&gt; / &lt;code&gt;requireMinRole()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/routes/documents.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The CRUD resource from the table above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/routes/admin.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Admin-only endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/routes/whoami.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Debug endpoint — inspect resolved roles/claims for your own token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tests/test.sh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The 25-case bash suite from Step 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;http/requests.http&lt;/code&gt; / &lt;code&gt;postman/*.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Same matrix, interactive&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                          ┌─────────────┐
  client ── access_token ─►  kc-rbac-api │
                          └──────┬──────┘
                                 │
                validationMode = local          validationMode = introspect
                                 │                            │
                    verify sig vs JWKS              POST /token/introspect
                    check iss/aud/exp               (client_id + client_secret)
                    read roles from claims           check active + aud
                                 │                            │
                                 └─────────► requireMinRole() / requireRole() ◄─────────┘
                                                       │
                                              200 / 201 / 204  or  403 insufficient_role
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two validation strategies, one audience check standing between them and&lt;br&gt;
"any signed token gets in," and a role hierarchy that's explicit about&lt;br&gt;
where it stops being hierarchical. That's the whole module.&lt;/p&gt;

&lt;p&gt;Next up in the series: &lt;strong&gt;module 05 — Identity Brokering&lt;/strong&gt;, wiring up two&lt;br&gt;
Keycloak realms in one Compose stack so a "corp" realm can broker into a&lt;br&gt;
"partner" realm via OIDC, without needing an actual external IdP to test&lt;br&gt;
against.&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>tutorial</category>
      <category>node</category>
      <category>oauth</category>
    </item>
    <item>
      <title>Keycloak from Scratch, Part 2 — Authorization Code + PKCE, Built by Hand</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 12:42:11 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/keycloak-from-scratch-part-2-authorization-code-pkce-built-by-hand-1e5b</link>
      <guid>https://dev.to/pilgrim2go/keycloak-from-scratch-part-2-authorization-code-pkce-built-by-hand-1e5b</guid>
      <description>&lt;p&gt;In Part 1 we logged in by sending a username and password directly&lt;br&gt;
to Keycloak. That's fine for a script, but no browser-facing app should&lt;br&gt;
ever do that — it means the app itself sees the raw password, and it&lt;br&gt;
can't participate in single sign-on. What every real login button should&lt;br&gt;
do instead is &lt;strong&gt;redirect the user to Keycloak&lt;/strong&gt; and get a token handed&lt;br&gt;
back. That's the Authorization Code flow, and for anything that can't&lt;br&gt;
keep a secret (a browser app, a mobile app), it needs a companion called&lt;br&gt;
&lt;strong&gt;PKCE&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We're going to implement it by hand — no &lt;code&gt;keycloak-js&lt;/code&gt;, no library — so&lt;br&gt;
every step is visible instead of hidden behind &lt;code&gt;keycloak.login()&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A public Keycloak client (no secret — a browser can't keep one)&lt;/li&gt;
&lt;li&gt;A plain HTML + vanilla JS page that performs the full redirect flow&lt;/li&gt;
&lt;li&gt;A small Flask API that validates the resulting token independently,
without ever talking to Keycloak per request&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Everything from Part 1 (Keycloak running via Docker Compose)&lt;/li&gt;
&lt;li&gt;Python 3 with &lt;code&gt;pip install flask flask-cors "pyjwt[crypto]"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A browser&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  1. Create a new realm and a public client
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Admin Console → &lt;strong&gt;Create realm&lt;/strong&gt; → &lt;code&gt;pkce-demo&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clients&lt;/strong&gt; → &lt;strong&gt;Create client&lt;/strong&gt; → Client ID: &lt;code&gt;pkce-spa-client&lt;/code&gt; → &lt;strong&gt;Next&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client authentication&lt;/strong&gt;: &lt;strong&gt;Off&lt;/strong&gt; — this is what makes it a public
client. There's no secret to steal because there isn't one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capability config&lt;/strong&gt;: check &lt;strong&gt;Standard flow&lt;/strong&gt;, uncheck &lt;strong&gt;Direct access
grants&lt;/strong&gt; (we don't want this client falling back to Part 1's flow) → &lt;strong&gt;Save&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings&lt;/strong&gt; tab:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Valid redirect URIs&lt;/strong&gt;: &lt;code&gt;http://localhost:5173/*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web origins&lt;/strong&gt;: &lt;code&gt;http://localhost:5173&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advanced&lt;/strong&gt; tab → scroll to &lt;strong&gt;Proof Key for Code Exchange Code
Challenge Method&lt;/strong&gt; → set to &lt;strong&gt;S256&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This last setting is the one that actually enforces PKCE server-side.&lt;br&gt;
  Without it, Keycloak will still &lt;em&gt;accept&lt;/em&gt; a PKCE challenge if the client&lt;br&gt;
  sends one, but it won't &lt;em&gt;require&lt;/em&gt; it — leaving the door open for a&lt;br&gt;
  client that skips PKCE entirely.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Create a role and a user
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Realm roles&lt;/strong&gt; → create &lt;code&gt;spa-user&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Users&lt;/strong&gt; → &lt;strong&gt;Add user&lt;/strong&gt; → username &lt;code&gt;dave&lt;/code&gt;, first/last name &lt;code&gt;Dave&lt;/code&gt;/&lt;code&gt;Demo&lt;/code&gt;
(see Part 1 if you're wondering why that matters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials&lt;/strong&gt; tab → password &lt;code&gt;dave&lt;/code&gt;, &lt;strong&gt;Temporary: Off&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role mapping&lt;/strong&gt; → assign &lt;code&gt;spa-user&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  3. The concept: what PKCE actually proves
&lt;/h2&gt;

&lt;p&gt;A confidential client proves its identity with a secret. A public client&lt;br&gt;
can't do that — so PKCE proves something different: &lt;strong&gt;that the app&lt;br&gt;
redeeming the authorization code is the same app that started the login&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser                                          Keycloak
  │  code_verifier = random(64 bytes)                │
  │  code_challenge = SHA256(code_verifier)          │
  ├──── GET /auth?code_challenge=... ───────────────►│
  │◄─── redirect back with ?code=... ─────────────────┤
  ├──── POST /token                                   │
  │        code=..., code_verifier=... ──────────────►│  (recomputes SHA256(code_verifier),
  │◄─── access_token, id_token ───────────────────────┤   compares to the original challenge)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;code_verifier&lt;/code&gt; never leaves the browser tab until the final token&lt;br&gt;
request. If an attacker somehow intercepts the redirect and grabs the&lt;br&gt;
&lt;code&gt;code&lt;/code&gt; (say, via a malicious app registered against the same custom&lt;br&gt;
URL scheme on mobile), they still can't redeem it — they don't have the&lt;br&gt;
verifier, and they can't derive it from the challenge, because SHA-256&lt;br&gt;
doesn't run backwards.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Generating the challenge (app.js)
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;base64UrlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/=+$/&lt;/span&gt;&lt;span class="p"&gt;,&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;randomString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;arr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;base64UrlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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;Nothing exotic — &lt;code&gt;crypto.subtle&lt;/code&gt; (native to every modern browser) does&lt;br&gt;
the hashing, and the verifier is just cryptographically random bytes.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. Kicking off the login
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codeVerifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codeChallenge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base64UrlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;codeVerifier&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// This never leaves the browser until step 6.&lt;/span&gt;
  &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pkce_code_verifier&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;codeVerifier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pkce_state&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pkce-spa-client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;response_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;code_challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;codeChallenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;code_challenge_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;S256&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="s2"&gt;`http://localhost:8080/realms/pkce-demo/protocol/openid-connect/auth?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;state&lt;/code&gt; parameter is a separate, unrelated safety net: a random value&lt;br&gt;
checked on the way back to guard against CSRF — someone tricking your&lt;br&gt;
browser into completing an authorization flow you didn't start.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. Handling the redirect back
&lt;/h2&gt;

&lt;p&gt;Keycloak sends the browser back to your &lt;code&gt;redirect_uri&lt;/code&gt; with &lt;code&gt;?code=...&amp;amp;state=...&lt;/code&gt;&lt;br&gt;
in the URL. This runs on page load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;returnedState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pkce_state&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;returnedState&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expectedState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;state mismatch — possible CSRF, aborting&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codeVerifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pkce_code_verifier&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;grant_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization_code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pkce-spa-client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;code_verifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;codeVerifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:8080/realms/pkce-demo/protocol/openid-connect/token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;access_token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id_token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceState&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keycloak re-derives &lt;code&gt;SHA256(code_verifier)&lt;/code&gt; server-side and compares it&lt;br&gt;
to the &lt;code&gt;code_challenge&lt;/code&gt; it received in step 5. If they match, and the&lt;br&gt;
&lt;code&gt;code&lt;/code&gt; hasn't already been redeemed or expired, you get tokens back.&lt;/p&gt;
&lt;h2&gt;
  
  
  7. Validating the token on the API side
&lt;/h2&gt;

&lt;p&gt;The API never talks to Keycloak per request — it fetches Keycloak's&lt;br&gt;
public signing keys once (JWKS) and verifies signatures locally:&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;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonify&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask_cors&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CORS&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PyJWKClient&lt;/span&gt;

&lt;span class="n"&gt;ISSUER&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/realms/pkce-demo&lt;/span&gt;&lt;span class="sh"&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;pkce-spa-client&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;jwks_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PyJWKClient&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ISSUER&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/protocol/openid-connect/certs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;origins&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:5173&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.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/me&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;me&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missing bearer token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;

    &lt;span class="n"&gt;signing_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwks_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_signing_key_from_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ISSUER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verify_aud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Keycloak's default audience is "account", not the client id — check
&lt;/span&gt;    &lt;span class="c1"&gt;# azp (authorized party) instead, which is always the requesting client.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;azp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unexpected azp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preferred_username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;preferred_username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;realm_access&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;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;roles&lt;/span&gt;&lt;span class="sh"&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;Because Keycloak signs tokens asymmetrically (RS256), the API only ever&lt;br&gt;
needs the &lt;em&gt;public&lt;/em&gt; half of the key pair — it can validate tokens without&lt;br&gt;
ever holding a shared secret with Keycloak.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 api/server.py &amp;amp;            &lt;span class="c"&gt;# :5001&lt;/span&gt;
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; http.server 5173 &amp;amp;      &lt;span class="c"&gt;# :5173, serving the SPA&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:5173&lt;/code&gt;, click &lt;strong&gt;Log in&lt;/strong&gt;, authenticate as&lt;br&gt;
&lt;code&gt;dave&lt;/code&gt;/&lt;code&gt;dave&lt;/code&gt;, and you should land back on the page with decoded ID&lt;br&gt;
token and access token claims printed, plus a working call to&lt;br&gt;
&lt;code&gt;/api/me&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened here
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public client, no secret&lt;/strong&gt; — PKCE substitutes possession of a
one-time verifier for a shared secret the client can't keep anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;state&lt;/code&gt; and &lt;code&gt;code_verifier&lt;/code&gt; are solving different problems&lt;/strong&gt; — CSRF
vs. authorization-code interception — don't conflate them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asymmetric JWT validation&lt;/strong&gt; means your API and your identity
provider never need to share a secret at all, unlike the client-secret
approach from Part 1.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Next up, Part 3&lt;/strong&gt;: back to server-to-server auth — client credentials&lt;br&gt;
grants, service accounts, and when Direct Grant (Part 1) is and isn't&lt;br&gt;
the right tool for machine identities.&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>oauth</category>
      <category>pkce</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Keycloak from Scratch, Part 1 — Realms, Roles, Clients &amp; Users (No Docker Magic, Just Clicks)</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 12:40:51 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/keycloak-from-scratch-part-1-realms-roles-clients-users-no-docker-magic-just-clicks-50fb</link>
      <guid>https://dev.to/pilgrim2go/keycloak-from-scratch-part-1-realms-roles-clients-users-no-docker-magic-just-clicks-50fb</guid>
      <description>&lt;p&gt;If you've ever inherited a Keycloak instance and had no idea what a&lt;br&gt;
"realm" actually isolates, or why some clients have secrets and others&lt;br&gt;
don't, this post is the ground floor. We're building one small realm by&lt;br&gt;
hand, in the Admin Console — no automation scripts, no realm-export JSON&lt;br&gt;
to skim past. Just enough clicking to make every concept stick.&lt;/p&gt;

&lt;p&gt;By the end you'll have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A dedicated realm&lt;/li&gt;
&lt;li&gt;Three roles representing a basic permission tier (&lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;viewer&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A confidential client configured for direct login&lt;/li&gt;
&lt;li&gt;Three users, each mapped to one role&lt;/li&gt;
&lt;li&gt;A working &lt;code&gt;curl&lt;/code&gt; command that logs in as each user and shows you their
role inside a decoded JWT&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Docker + Docker Compose&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;python3&lt;/code&gt; (for a one-line JWT decode — no library needed)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  1. Start Keycloak
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;keycloak&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;quay.io/keycloak/keycloak:26.7.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keycloak-fundamentals&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;start-dev&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;KEYCLOAK_ADMIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admin&lt;/span&gt;
      &lt;span class="na"&gt;KEYCLOAK_ADMIN_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admin&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Give it about 30 seconds, then open &lt;strong&gt;&lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt;&lt;/strong&gt; and log in&lt;br&gt;
with &lt;code&gt;admin&lt;/code&gt; / &lt;code&gt;admin&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;start-dev&lt;/code&gt; is a dev-mode flag — it skips TLS and some production&lt;br&gt;
checks so you can iterate quickly. Never run this flag in production.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  2. Create a realm
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;realm&lt;/strong&gt; is Keycloak's top-level tenancy boundary — its own users,&lt;br&gt;
roles, clients, and even its own login theme, fully isolated from every&lt;br&gt;
other realm on the same server. Think of it as a separate mini&lt;br&gt;
directory service you can spin up per environment, per product, or per&lt;br&gt;
customer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Top-left realm dropdown → &lt;strong&gt;Create realm&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Realm name: &lt;code&gt;fundamentals&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Create&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  3. Create three realm roles
&lt;/h2&gt;

&lt;p&gt;Roles are how you express "what can this identity do" without hardcoding&lt;br&gt;
usernames into your authorization logic. We're using &lt;strong&gt;realm roles&lt;/strong&gt;&lt;br&gt;
here — visible to every client in the realm, as opposed to &lt;strong&gt;client&lt;br&gt;
roles&lt;/strong&gt;, which are scoped to one specific client. (We'll hit client&lt;br&gt;
roles in a later post when we build a proper RBAC API.)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Left sidebar → &lt;strong&gt;Realm roles&lt;/strong&gt; → &lt;strong&gt;Create role&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Create three, one at a time: &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;viewer&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  4. Create a confidential client
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Left sidebar → &lt;strong&gt;Clients&lt;/strong&gt; → &lt;strong&gt;Create client&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Client ID: &lt;code&gt;kc-fundamentals-app&lt;/code&gt; → &lt;strong&gt;Next&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client authentication&lt;/strong&gt;: toggle &lt;strong&gt;On&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the setting that makes a client "confidential" — it gets a&lt;br&gt;
  secret, and Keycloak requires that secret before it'll hand out a&lt;br&gt;
  token. A &lt;strong&gt;public client&lt;/strong&gt; (toggle off) has no secret at all, because&lt;br&gt;
  anything shipped to a browser or mobile app can't actually keep one&lt;br&gt;
  secret. We'll build a public client in Part 2.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capability config&lt;/strong&gt;: check &lt;strong&gt;Direct access grants&lt;/strong&gt;, leave
&lt;strong&gt;Standard flow&lt;/strong&gt; on if you like (harmless either way for this demo) → &lt;strong&gt;Save&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Go to the &lt;strong&gt;Credentials&lt;/strong&gt; tab and copy the &lt;strong&gt;Client secret&lt;/strong&gt; — you'll
paste it into the &lt;code&gt;curl&lt;/code&gt; command later&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  5. Create three users
&lt;/h2&gt;

&lt;p&gt;Repeat this for &lt;code&gt;alice&lt;/code&gt;, &lt;code&gt;bob&lt;/code&gt;, and &lt;code&gt;carol&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Users&lt;/strong&gt; → &lt;strong&gt;Add user&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Username: &lt;code&gt;alice&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First name&lt;/strong&gt;: &lt;code&gt;Alice&lt;/code&gt;, &lt;strong&gt;Last name&lt;/strong&gt;: &lt;code&gt;Demo&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't skip this. Keycloak has a built-in "Update Profile" required&lt;br&gt;
  action that checks profile completeness &lt;strong&gt;dynamically, at login&lt;br&gt;
  time&lt;/strong&gt; — not something you'll see if you inspect the user's stored&lt;br&gt;
  data beforehand. An empty first/last name silently blocks login later&lt;br&gt;
  with a generic &lt;code&gt;"Account is not fully set up"&lt;/code&gt; error that has nothing&lt;br&gt;
  to do with the password you set. This one cost me an embarrassing&lt;br&gt;
  amount of debugging the first time — save yourself the trouble.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Create&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials&lt;/strong&gt; tab → &lt;strong&gt;Set password&lt;/strong&gt; → &lt;code&gt;alice&lt;/code&gt; → toggle
&lt;strong&gt;Temporary: Off&lt;/strong&gt; → &lt;strong&gt;Save&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Temporary" means the user is forced to change it on next login —&lt;br&gt;
  fine for humans clicking through a browser flow, but it'll break a&lt;br&gt;
  scripted or API-driven login every time, since there's no browser to&lt;br&gt;
  serve the "set a new password" screen. Off, for this demo.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Role mapping&lt;/strong&gt; tab → &lt;strong&gt;Assign role&lt;/strong&gt; → pick &lt;code&gt;admin&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repeat for &lt;code&gt;bob&lt;/code&gt; → &lt;code&gt;editor&lt;/code&gt; and &lt;code&gt;carol&lt;/code&gt; → &lt;code&gt;viewer&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. One more thing to check: OTP
&lt;/h2&gt;

&lt;p&gt;Some Keycloak versions ship the built-in &lt;code&gt;direct grant&lt;/code&gt; authentication&lt;br&gt;
flow with its conditional-OTP branch misconfigured as &lt;code&gt;Required&lt;/code&gt; instead&lt;br&gt;
of &lt;code&gt;Conditional&lt;/code&gt;. If that's the case on your instance, &lt;strong&gt;every&lt;/strong&gt; login&lt;br&gt;
attempt from a user without an OTP device configured — which is all&lt;br&gt;
three of ours — gets rejected before the password is even checked.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt; (left sidebar) → &lt;strong&gt;Flows&lt;/strong&gt; tab → select &lt;strong&gt;direct grant&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Find the OTP-related row at the &lt;strong&gt;top level&lt;/strong&gt; (often labeled something
like &lt;em&gt;"Direct Grant - Conditional OTP"&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;If it's not already &lt;code&gt;Conditional&lt;/code&gt; or &lt;code&gt;Disabled&lt;/code&gt;, set it to &lt;strong&gt;Disabled&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  7. Log in and inspect the token
&lt;/h2&gt;

&lt;p&gt;This is a &lt;strong&gt;Direct Access Grant&lt;/strong&gt; (also called Resource Owner Password&lt;br&gt;
Credentials, or ROPC) — the client trades a username and password&lt;br&gt;
directly for a token, no browser redirect involved. It's convenient for&lt;br&gt;
this fundamentals demo and for machine-to-machine scripts, but it means&lt;br&gt;
the client sees the raw password, so it's a poor fit for anything&lt;br&gt;
user-facing. (Part 2 covers the flow you actually want for a browser app:&lt;br&gt;
Authorization Code + PKCE.)&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;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/realms/fundamentals/protocol/openid-connect/token &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/x-www-form-urlencoded"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=password"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id=kc-fundamentals-app"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret=PASTE_YOUR_SECRET_HERE"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"username=alice"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"password=alice"&lt;/span&gt; | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns an &lt;code&gt;access_token&lt;/code&gt;, decode its payload to see the role&lt;br&gt;
claim in the flesh:&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;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/realms/fundamentals/protocol/openid-connect/token &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=password"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id=kc-fundamentals-app"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret=PASTE_YOUR_SECRET_HERE"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"username=alice"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"password=alice"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import sys, json, base64
token = json.load(sys.stdin)['access_token']
payload = token.split('.')[1]
payload += '=' * (-len(payload) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"realm_access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"admin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default-roles-fundamentals"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"offline_access"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uma_authorization"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;admin&lt;/code&gt; is the one we assigned. The other three are Keycloak's own&lt;br&gt;
housekeeping roles every user gets by default.&lt;/p&gt;

&lt;p&gt;Repeat with &lt;code&gt;bob&lt;/code&gt;/&lt;code&gt;bob&lt;/code&gt; and &lt;code&gt;carol&lt;/code&gt;/&lt;code&gt;carol&lt;/code&gt; — you should see &lt;code&gt;editor&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;viewer&lt;/code&gt; respectively.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened here
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Realm isolation&lt;/strong&gt;: &lt;code&gt;fundamentals&lt;/code&gt; never sees or touches any other
realm's users, clients, or roles — that's the whole point of realms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confidential client + Direct Grant&lt;/strong&gt;: fine for this demo and for
trusted server-side/CLI tooling, wrong for a browser SPA (that's Part 2).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role claims propagate automatically&lt;/strong&gt;: once a realm role is assigned
to a user, Keycloak embeds it in every token that user gets, with zero
extra configuration — this is what your API or gateway will check
against later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Troubleshooting: "Account is not fully set up"
&lt;/h2&gt;

&lt;p&gt;If you hit this, it's one of exactly two causes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;OTP required in the direct grant flow&lt;/strong&gt; (see step 6 above)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incomplete user profile&lt;/strong&gt; — missing first/last name (see step 5).
This one is genuinely invisible from the outside: it's evaluated live
at login, not stored anywhere you can query beforehand.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Check both. They're independent, and fixing only one can still leave you&lt;br&gt;
stuck.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next up, Part 2&lt;/strong&gt;: we swap Direct Grant for the flow every real&lt;br&gt;
browser app should use — Authorization Code with PKCE — and build a&lt;br&gt;
vanilla-JS SPA that implements the whole thing by hand, no auth library,&lt;br&gt;
so every step of the exchange is visible.&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>iam</category>
      <category>oauth</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
