<?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: Akarshan Gandotra</title>
    <description>The latest articles on DEV Community by Akarshan Gandotra (@akarshan).</description>
    <link>https://dev.to/akarshan</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%2F69510%2F5056764e-527e-442d-860b-cab2dbbf57cd.jpg</url>
      <title>DEV Community: Akarshan Gandotra</title>
      <link>https://dev.to/akarshan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/akarshan"/>
    <language>en</language>
    <item>
      <title>[Boost]</title>
      <dc:creator>Akarshan Gandotra</dc:creator>
      <pubDate>Fri, 30 Oct 2026 06:32:07 +0000</pubDate>
      <link>https://dev.to/akarshan/-2lm8</link>
      <guid>https://dev.to/akarshan/-2lm8</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/akarshan/zero-standing-access-for-ai-agents-let-users-lend-access-one-session-at-a-time-1nep" class="crayons-story__hidden-navigation-link"&gt;Zero Standing Access for AI Agents: Let Users Lend Access, One Session at a Time&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/akarshan" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F69510%2F5056764e-527e-442d-860b-cab2dbbf57cd.jpg" alt="akarshan profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/akarshan" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Akarshan Gandotra
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Akarshan Gandotra
                
                
              
              &lt;div id="story-author-preview-content-4939090" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/akarshan" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F69510%2F5056764e-527e-442d-860b-cab2dbbf57cd.jpg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Akarshan Gandotra&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/akarshan/zero-standing-access-for-ai-agents-let-users-lend-access-one-session-at-a-time-1nep" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Oct 29&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/akarshan/zero-standing-access-for-ai-agents-let-users-lend-access-one-session-at-a-time-1nep" id="article-link-4939090"&gt;
          Zero Standing Access for AI Agents: Let Users Lend Access, One Session at a Time
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/security"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;security&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/architecture"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;architecture&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/identity"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;identity&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/akarshan/zero-standing-access-for-ai-agents-let-users-lend-access-one-session-at-a-time-1nep" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/raised-hands-74b2099fd66a39f2d7eed9305ee0f4553df0eb7b4f11b01b6b1b499973048fe5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;8&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/akarshan/zero-standing-access-for-ai-agents-let-users-lend-access-one-session-at-a-time-1nep#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              15&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            13 min read
          &lt;/small&gt;
          
          &lt;div class="only-sidebar-menu-item trusted-visible-block print-hidden align-center"&gt;
                
                    
                
          &lt;/div&gt;

        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>agents</category>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>Zero Standing Access for AI Agents: Let Users Lend Access, One Session at a Time</title>
      <dc:creator>Akarshan Gandotra</dc:creator>
      <pubDate>Thu, 29 Oct 2026 07:07:11 +0000</pubDate>
      <link>https://dev.to/akarshan/zero-standing-access-for-ai-agents-let-users-lend-access-one-session-at-a-time-1nep</link>
      <guid>https://dev.to/akarshan/zero-standing-access-for-ai-agents-let-users-lend-access-one-session-at-a-time-1nep</guid>
      <description>&lt;p&gt;Agents are doing real work in our products now. They read records, draft emails, file tickets and reconcile invoices. To do any of that they need access, and when it's time to give it to them, most teams reach for one of two tools. Both are wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option one: a service account.&lt;/strong&gt; The agent gets its own credentials with a fixed set of permissions. It works, but it's standing access that no user ever agreed to, it isn't bound to any user, and every action shows up in the audit log as "the robot did it".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option two: let the agent act as the user.&lt;/strong&gt; Pass the user's token through, or mint one that impersonates them. Now the agent can do everything the user can do. A task that needed one key gets the whole keyring.&lt;/p&gt;

&lt;p&gt;I think agents deserve to be a first-class principal in IAM, next to users and service accounts, built on one rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An agent owns no access. It borrows access from the person it works for, one request at a time, with that person's consent, for the current session only, and never more than that person has.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rest of this post is what that rule looks like in practice: the entities IAM needs, agent templates, a permission catalog that IAM hosts as a tool, three user journeys with wireframes, and the parts I'm still arguing with myself about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every agent session starts with &lt;strong&gt;zero access&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The agent asks IAM for what it needs &lt;strong&gt;when it needs it&lt;/strong&gt;, through tools, and gives a reason.&lt;/li&gt;
&lt;li&gt;The user approves in the chat, the way AI coding tools ask before running a command: &lt;strong&gt;Allow once, Always allow, Deny&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Grants last &lt;strong&gt;for the session only&lt;/strong&gt;. "Always allow" saves a rule that skips the prompt next time. It doesn't preload access.&lt;/li&gt;
&lt;li&gt;An agent can &lt;strong&gt;never hold more than its user has right now&lt;/strong&gt;, and some permissions are never available to agents at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent templates&lt;/strong&gt; define what a kind of agent may ever ask for, plus the context it needs to ask well.&lt;/li&gt;
&lt;li&gt;IAM hosts a &lt;strong&gt;permission catalog tool&lt;/strong&gt;, so builders and agents can find out what exists, what's risky and what's off-limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the industry is today
&lt;/h2&gt;

&lt;p&gt;As of October 2026 there's no finished standard for agent access. There is a lot of convergence, though.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Standards and guidance&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;Model Context Protocol&lt;/strong&gt; authorization spec builds agent tool access on OAuth 2.1. Each MCP server publishes which authorization server it trusts, and each token is bound to one server.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;IETF OAuth working group&lt;/strong&gt; has a draft for Transaction Tokens: short-lived signed tokens that carry the user and the context along a chain of service calls. Several individual drafts propose agent-specific profiles (agent grants, agent instances, identity assertion grants), and none has been adopted by a working group yet.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;OpenID Foundation&lt;/strong&gt; whitepaper &lt;em&gt;Identity Management for Agentic AI&lt;/em&gt; says existing standards cover simple agents inside one organization, and recommends delegation through OAuth Token Exchange (RFC 8693).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NIST's NCCoE&lt;/strong&gt; started a project on software and AI agent identity. Most of its 600+ public comments asked to build on existing standards, and agents that start other agents are flagged as an open problem.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;OWASP Top 10 for Agentic Applications&lt;/strong&gt; lists identity and privilege abuse as a top risk. It recommends short-lived, narrowly scoped credentials per task and per agent, a fresh check before each privileged step, and human approval before an escalation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What vendors ship&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Entra Agent ID&lt;/strong&gt; gives each agent its own identity, created from a reusable blueprint and owned by a named human sponsor. High-risk permissions are blocked for agents even when someone consents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Okta Cross App Access&lt;/strong&gt; lets the company's identity provider decide which agent can reach which app. Its tokens carry the user as &lt;code&gt;sub&lt;/code&gt; and the agent as &lt;code&gt;act&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth0 for AI Agents&lt;/strong&gt; adds a vault for third-party tokens, approval pushed to the user's phone (CIBA) for actions that need consent, and fine-grained checks on every action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Bedrock AgentCore Identity&lt;/strong&gt; swaps the user's token for a narrower one per downstream service, carrying both identities.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The convergence is real: agents get their own identity, delegation goes through token exchange with the agent recorded in &lt;code&gt;act&lt;/code&gt;, credentials are short-lived, and some permissions are blocked outright. What's still mostly missing is the part in the middle. What happens between "the agent asked" and "the agent got it"? Who consents, to what, for how long, and what do subagents inherit? That's the part this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven principles
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Zero standing access.&lt;/strong&gt; An agent, and every new session of it, starts with nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never more than the user.&lt;/strong&gt; Every grant is checked against the user's current access, both when it's granted and at every token refresh.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the user can consent.&lt;/strong&gt; Approval needs the user's own credential. The agent can ask, but it can't approve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session-scoped.&lt;/strong&gt; Grants end when the session ends or is revoked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inherit down, never up.&lt;/strong&gt; A subagent gets at most its parent's access, and what a subagent is granted stays with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remember the consent, not the access.&lt;/strong&gt; Saved rules let a user skip the prompt next time. Each session still starts at zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything is traceable.&lt;/strong&gt; Every request, decision, grant and call records the agent, the user and the session.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A new entity in IAM: the agent
&lt;/h2&gt;

&lt;p&gt;Here's how an agent compares with the two options we have today.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Service account&lt;/th&gt;
&lt;th&gt;Acting as the user&lt;/th&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Acts for&lt;/td&gt;
&lt;td&gt;Itself&lt;/td&gt;
&lt;td&gt;A user&lt;/td&gt;
&lt;td&gt;A user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access when it starts&lt;/td&gt;
&lt;td&gt;Whatever an admin granted&lt;/td&gt;
&lt;td&gt;All of the user's access&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who decides its access&lt;/td&gt;
&lt;td&gt;An admin, up front&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;td&gt;The user, per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bounded by the user&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Equal to the user&lt;/td&gt;
&lt;td&gt;Always at or below the user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How long access lasts&lt;/td&gt;
&lt;td&gt;Until someone changes it&lt;/td&gt;
&lt;td&gt;The token's lifetime&lt;/td&gt;
&lt;td&gt;The session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit shows&lt;/td&gt;
&lt;td&gt;The service account&lt;/td&gt;
&lt;td&gt;The user&lt;/td&gt;
&lt;td&gt;The agent, the user and the session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stop one run&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To support this, IAM and user management need a few new things: an &lt;strong&gt;agent template&lt;/strong&gt; (what a kind of agent may ever ask for), an &lt;strong&gt;agent&lt;/strong&gt; (a deployed agent with its own credential), an &lt;strong&gt;agent session&lt;/strong&gt; (one run for one user), &lt;strong&gt;grants&lt;/strong&gt;, &lt;strong&gt;access requests&lt;/strong&gt;, &lt;strong&gt;consent rules&lt;/strong&gt; and an &lt;strong&gt;activity log&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F39pu0z791no2byisb1gj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F39pu0z791no2byisb1gj.png" alt="Data model for agents in IAM: templates, agents, sessions, grants, requests, consent rules and the activity log" width="800" height="823"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One design choice matters more than it looks: none of the existing grant tables (role assignments, group memberships, user permissions) ever point at an agent. Agent grants live in their own table and die with their session, so standing access for an agent can't exist even by accident.&lt;/p&gt;

&lt;h3&gt;
  
  
  What an agent can borrow
&lt;/h3&gt;

&lt;p&gt;Most systems have more than one kind of permission, and an agent should be able to borrow each of them, with the same rules applied to all.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;Controls&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API permissions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Which endpoints and actions it can call&lt;/td&gt;
&lt;td&gt;Accounts API · Read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data permissions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Which records and fields it can see&lt;/td&gt;
&lt;td&gt;Accounts in my region&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Asset permissions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Which objects it can open&lt;/td&gt;
&lt;td&gt;Q3 pipeline dashboard · View&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tool and outbound permissions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Which tools it can run and where data can go&lt;/td&gt;
&lt;td&gt;Send email to customers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Never delegable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nothing an agent can ever get&lt;/td&gt;
&lt;td&gt;IAM admin rights, emergency access, managing agents or consent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your system may have more kinds, such as feature flags, billing actions or workflow approvals. The rules don't change. Each kind is a type of grant with its own enforcement point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent templates: permissions plus context
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;agent template&lt;/strong&gt; is a reusable definition of one kind of job. It holds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Purpose:&lt;/strong&gt; what the agent is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ceiling:&lt;/strong&gt; the most it may ever ask for, by kind of permission. A request outside it is refused, whatever the user has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context:&lt;/strong&gt; how the agent should reason about access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modes allowed, limits&lt;/strong&gt; (session lifetime, subagent depth, request rate) and &lt;strong&gt;child templates&lt;/strong&gt; it may start as subagents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sensitive items&lt;/strong&gt;, which always prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sponsor&lt;/strong&gt;, the human accountable for it, and a &lt;strong&gt;version&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ceiling stops an agent from overreaching. It doesn't make the agent ask &lt;em&gt;well&lt;/em&gt;. Left alone, an agent will often request its whole ceiling up front, because that's the easiest way to never be blocked. The context is there so the agent does the analysis every time: what's the smallest set of access this task needs, when is sensitive data actually justified, and when should it give access back.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Ask only for accounts in the renewal window, never the whole book. Prefer read access. Ask for contract pricing only when the user asks about price. Release access as soon as a brief is done.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Organizations can turn a template on and narrow it, but never widen it. A new version that widens the ceiling asks users for consent again. A version that only narrows it doesn't.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fta4k23mfc3wyahgn8djo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fta4k23mfc3wyahgn8djo.png" alt="Wireframe: building an agent template, with IAM's permission catalog in the right pane" width="800" height="528"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The permission catalog: an IAM tool for builders and agents
&lt;/h2&gt;

&lt;p&gt;Here's a problem I didn't expect. People building agents don't know the permission model, and agents guess. IAM is the only system that knows what permissions exist, what each one unlocks, how risky it is, which data is sensitive and what's never delegable.&lt;/p&gt;

&lt;p&gt;So IAM should publish that knowledge as a tool. &lt;code&gt;iam.describe_permissions(task)&lt;/code&gt; searches the catalog for a task. &lt;code&gt;iam.explain(permission)&lt;/code&gt; says what a permission unlocks, how risky it is, where it's enforced and whether an agent can have it. Builders use it to write ceilings. Agents use it at run time to plan the smallest request. Security reviewers use it to see what a new template version adds.&lt;/p&gt;

&lt;p&gt;The same IAM tools give the agent its access during a session:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iam.describe_permissions(task)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Finds the permissions that fit a task, with risk and sensitivity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iam.request_access(items, reason)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Asks for access for this session. Each item is granted, refused or sent to the user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iam.my_access()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lists what this session holds right now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iam.start_subagent(template, read_only)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Starts a subagent from an allowed child template&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iam.release_access(items)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gives access back early&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iam.end_session()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ends the session and every subagent, and revokes all grants&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The app makes these calls for the agent and holds the tokens. The model sees each result, never a token.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F80u5ggjw41gsftorkgvi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F80u5ggjw41gsftorkgvi.png" alt="How a template gets built, reviewed, published and turned on" width="796" height="61"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Journey 1: building and publishing a template
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A builder describes the job&lt;/strong&gt; ("prepare renewal briefs for an account manager") and asks the permission catalog what fits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They draft the template:&lt;/strong&gt; the ceiling, the context and the limits. The catalog flags contract pricing as sensitive, outbound email as high risk, and billing admin as never available to agents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security reviews it.&lt;/strong&gt; Because every item comes from the catalog, the review is a diff: what does this version add, and how risky is it?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The template is published as v3.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An organization admin turns it on&lt;/strong&gt; and may narrow it, for example by removing contract pricing or turning off bypass mode for their users.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Journey 2: a user's session, end to end
&lt;/h2&gt;

&lt;p&gt;The user opens a three-pane app: sessions on the left, the chat in the middle, and the session's access on the right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: the session starts with nothing.&lt;/strong&gt; The user types the task and picks a mode from the message box. The app exchanges the user's token, its own credential and the template for a session token that carries no permissions at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvg5yrqlp41wv6bknl1yw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvg5yrqlp41wv6bknl1yw.png" alt="Wireframe 1: starting a session, with the permission mode menu open" width="800" height="528"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: the agent asks.&lt;/strong&gt; To find this quarter's renewals, the agent calls &lt;code&gt;iam.request_access&lt;/code&gt; for three items: an API permission, a data permission and an asset permission, with a reason. IAM checks each item: it's inside the template's ceiling, the user has it right now, and it isn't on the never-delegable list. No saved rule covers these, and the mode is Ask, so the call comes back as a prompt in the chat.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp1tlmd99d3xk2taqo2ly.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp1tlmd99d3xk2taqo2ly.png" alt="Wireframe 2: the agent's request becomes a permission prompt in the chat" width="800" height="528"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: the user approves.&lt;/strong&gt; They choose Always allow. IAM records three grants on the session and three rules, then issues a fresh token with exactly those permissions. The decision only counts because it arrives with the user's own token. An agent can't approve its own request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: the agent works, and starts a subagent.&lt;/strong&gt; It calls the accounts API with its own token. Then it starts an email drafter subagent, read-only. The drafter inherits the three grants as read-only, asks for its own "Email templates · Write", and gets it once. That grant stays with the drafter and doesn't flow up to the parent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcf9pc7gqf3ss1s6liepf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcf9pc7gqf3ss1s6liepf.png" alt="Wireframe 3: tool calls in the chat, and grants and the subagent in the right pane" width="800" height="528"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: a sensitive request, and a change of access.&lt;/strong&gt; The drafter asks for contract pricing. Sensitive items prompt in every mode and can never be saved as Always allow, and the user denies it. That afternoon the user loses access to the pipeline dashboard. At the next token refresh, 15 minutes at most, the agent loses it too. Nobody had to touch the agent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcjaqyz2a1dtr75ghfqhc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcjaqyz2a1dtr75ghfqhc.png" alt="Wireframe 4: a sensitive request that only offers Allow once, and access removed after the user lost it" width="800" height="528"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6: the session ends.&lt;/strong&gt; The agent calls &lt;code&gt;iam.end_session&lt;/code&gt;, or the user clicks End session, or it expires. The session and its subagents are revoked. The saved rules stay.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3cqm312ftda93x5gi5q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3cqm312ftda93x5gi5q.png" alt="A session from start to finish" width="800" height="541"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Journey 3: the next day, and the audit
&lt;/h2&gt;

&lt;p&gt;The user starts a new session, and it holds nothing. The agent asks for the same three items. Two match saved rules and are granted with no prompt. The dashboard has a saved rule too, but the user no longer has access to it, so it's refused. &lt;strong&gt;A rule never grants more than the user has.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhcqlmh2mzjgrjgw84qgk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhcqlmh2mzjgrjgw84qgk.png" alt="Wireframe 5: saved rules skip the prompt, and the rule for lost access is refused" width="800" height="528"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Later, someone in compliance asks what the agent accessed for this user last week. The activity log answers per session: what was asked, who decided and how (prompt or rule), what was granted and what was called. When the template ships a version that widens its ceiling, users are asked again once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modes and rules, like your AI coding tool
&lt;/h2&gt;

&lt;p&gt;If you've used an AI coding tool, you already know the shape of this.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session modes&lt;/strong&gt;, picked when the session starts. &lt;strong&gt;Ask&lt;/strong&gt; prompts for anything no rule covers. &lt;strong&gt;Auto&lt;/strong&gt; also approves low-risk items, such as read-only API access, without a prompt. &lt;strong&gt;Bypass&lt;/strong&gt; approves anything the user has except sensitive items, and an organization can turn it off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consent rules&lt;/strong&gt;, saved from a prompt: allow, deny or ask, per template and per permission.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The order matters. The template's ceiling, the user's current access and the never-delegable list always win. Then deny, ask and allow rules apply, and only then the mode. The mode is stored on the server's session record. If the agent could send it with the request, a prompt-injected agent would simply claim bypass.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F61seq9nb7tq9fs9gz0gz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F61seq9nb7tq9fs9gz0gz.png" alt="How each requested item is decided" width="800" height="1194"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood
&lt;/h2&gt;

&lt;p&gt;The mechanics are mostly standard. The app does an OAuth 2.0 Token Exchange (RFC 8693) when the session starts, and the agent's identity rides in the &lt;code&gt;act&lt;/code&gt; claim, with one more nested &lt;code&gt;act&lt;/code&gt; for each subagent level. Tokens live 15 minutes at most and are re-issued after every grant. The user's current access is re-checked at every issue, so losing a role removes it from the agent within one refresh. Revoking a session bumps a version number that every token carries.&lt;/p&gt;

&lt;p&gt;Each kind of permission is enforced where it already lives:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpq5jt4te6f4kw72mp1fv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpq5jt4te6f4kw72mp1fv.png" alt="Where each kind of permission is enforced" width="800" height="596"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two lessons that weren't obvious until I worked through them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data restrictions come from the user, always.&lt;/strong&gt; If an agent's data access were just the data permissions it asked for, it would skip the user's restrictions, such as masked fields or a block on certain sensitive records. The agent would see things the user can't. Grants come from the session, restrictions come from the user, and restrictions can't be requested at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object access needs a live dual check.&lt;/strong&gt; API permissions get recomputed every time a token is issued. Object permissions are often stored as relationships written once, at grant time. The only thing that notices the user lost access to a dashboard is a check that the session has it &lt;strong&gt;and&lt;/strong&gt; the user still has it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What can go wrong
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;Guardrail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompt injection makes the agent ask for broad access&lt;/td&gt;
&lt;td&gt;Template ceiling, deny rules, the never-delegable list, the reason shown in every prompt, and a rate limit on requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The agent approves its own request&lt;/td&gt;
&lt;td&gt;Decisions only count with the user's own token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The agent claims a more permissive mode&lt;/td&gt;
&lt;td&gt;The mode lives on the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Escalation through admin tools&lt;/td&gt;
&lt;td&gt;IAM admin rights are never delegable, so an agent can't raise its own user's access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subagents combine into something no template allows&lt;/td&gt;
&lt;td&gt;Review a template together with its child templates, and keep tool and outbound permissions in the ceiling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data leaves through a tool IAM doesn't govern&lt;/td&gt;
&lt;td&gt;Tool and outbound permissions are a kind of grant, enforced by a tool gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background agents with nobody at the screen&lt;/td&gt;
&lt;td&gt;Push approvals to the user's phone (CIBA) instead of letting saved rules quietly become standing access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consent fatigue&lt;/td&gt;
&lt;td&gt;Modes, saved rules, and templates whose context keeps requests small&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The part I'm still arguing about: whose name goes in &lt;code&gt;sub&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;The convention, in RFC 8693 and in what Okta and AWS ship, is &lt;code&gt;sub&lt;/code&gt; = the user and &lt;code&gt;act&lt;/code&gt; = the agent. It reads naturally: the agent acts on behalf of the user.&lt;/p&gt;

&lt;p&gt;I lean the other way: &lt;code&gt;sub&lt;/code&gt; = the agent session, with the user as &lt;code&gt;on_behalf_of&lt;/code&gt;. The reason is failure mode. In a large system, some service somewhere will look up permissions by &lt;code&gt;sub&lt;/code&gt;. With the user in &lt;code&gt;sub&lt;/code&gt;, that service hands the agent the user's full access. With the session in &lt;code&gt;sub&lt;/code&gt;, it finds nothing and fails closed.&lt;/p&gt;

&lt;p&gt;The cost is that standard libraries and MCP clients expect the convention. If you follow it, you need confidence that every service checks the token's own permissions and never re-derives them from &lt;code&gt;sub&lt;/code&gt;. I'd love to hear which way you went and why.&lt;/p&gt;

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

&lt;p&gt;Agents will soon outnumber the people in most identity systems. Least privilege has always been more of an aspiration than a default, because people's tasks are hard to bound. Agents are different: a session gives us a natural boundary, and a chat gives us a natural place to ask. Let's use both. Start every agent at zero, let the user lend what the task needs, and take it back when the session ends.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization" rel="noopener noreferrer"&gt;MCP authorization specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-transaction-tokens/" rel="noopener noreferrer"&gt;IETF: Transaction Tokens&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/draft-mishra-oauth-agent-grants/" rel="noopener noreferrer"&gt;IETF: OAuth profile for delegated AI agent authorization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openid.net/wp-content/uploads/2025/10/Identity-Management-for-Agentic-AI.pdf" rel="noopener noreferrer"&gt;OpenID Foundation: Identity Management for Agentic AI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.nccoe.nist.gov/projects/software-and-ai-agent-identity-and-authorization" rel="noopener noreferrer"&gt;NIST NCCoE: Software and AI Agent Identity and Authorization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://genai.owasp.org/2025/12/09/owasp-top-10-for-agentic-applications-the-benchmark-for-agentic-security-in-the-age-of-autonomous-ai/" rel="noopener noreferrer"&gt;OWASP Top 10 for Agentic Applications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/entra/agent-id/authorization-agent-id" rel="noopener noreferrer"&gt;Microsoft Entra Agent ID: authorization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.okta.com/docs/guides/ai-agent-token-exchange/-/main/" rel="noopener noreferrer"&gt;Okta: AI agent token exchange&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://auth0.com/docs/get-started/auth0-for-ai-agents" rel="noopener noreferrer"&gt;Auth0 for AI Agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-outbound-auth.html" rel="noopener noreferrer"&gt;AWS Bedrock AgentCore: outbound auth&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc8693" rel="noopener noreferrer"&gt;RFC 8693: OAuth 2.0 Token Exchange&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>architecture</category>
      <category>identity</category>
    </item>
    <item>
      <title>The CoreDNS Black Hole: how one dead DNS pod broke our API gateway</title>
      <dc:creator>Akarshan Gandotra</dc:creator>
      <pubDate>Mon, 21 Sep 2026 11:53:12 +0000</pubDate>
      <link>https://dev.to/akarshan/the-coredns-black-hole-how-one-dead-dns-pod-broke-our-api-gateway-4hh3</link>
      <guid>https://dev.to/akarshan/the-coredns-black-hole-how-one-dead-dns-pod-broke-our-api-gateway-4hh3</guid>
      <description>&lt;p&gt;Our API gateway started handing this back to roughly a third of all requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Upstream Service Unavailable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Upstream Service unreachable"&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;Restart the gateway and everything is fine. A day later, it's back. No deploy, no code change, no traffic spike, nothing in the backend logs — the backends were healthy the whole time.&lt;/p&gt;

&lt;p&gt;The cause was one open network socket pointing at a pod that no longer existed. The mechanism is genuinely strange, so let's start with an analogy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The analogy: the receptionist who went home
&lt;/h2&gt;

&lt;p&gt;Your gateway is a clerk at a busy counter. Before serving anyone, the clerk needs a phone number — &lt;em&gt;"which desk handles billing today?"&lt;/em&gt; — so it calls the office directory.&lt;/p&gt;

&lt;p&gt;A sensible clerk dials the directory's main switchboard every time. Our clerk did something subtly different: it dialled &lt;strong&gt;once&lt;/strong&gt;, got through to &lt;strong&gt;Priya&lt;/strong&gt; at the directory, and then &lt;strong&gt;kept that line open forever&lt;/strong&gt;, because opening a line costs time and Priya always answers.&lt;/p&gt;

&lt;p&gt;One night the building is reorganised. Priya's desk is removed. Her extension now goes nowhere.&lt;/p&gt;

&lt;p&gt;Next morning the clerk picks up the same line, asks its question… and hears nothing. It waits. Still nothing. It gives up, tells the customer &lt;em&gt;"billing is unavailable"&lt;/em&gt; — and then, crucially, &lt;strong&gt;asks the very same dead line again for the next customer.&lt;/strong&gt; And the next. Forever.&lt;/p&gt;

&lt;p&gt;Why doesn't it redial? Because the clerk only redials when the phone reports &lt;strong&gt;"this line is disconnected."&lt;/strong&gt; Silence isn't an error. Silence is just silence, so it waits again.&lt;/p&gt;

&lt;p&gt;That's the whole bug. Now swap in the real names:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Analogy&lt;/th&gt;
&lt;th&gt;Reality&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the clerk&lt;/td&gt;
&lt;td&gt;an &lt;strong&gt;nginx&lt;/strong&gt; worker process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the office directory&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;CoreDNS&lt;/strong&gt; — Kubernetes' internal DNS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;keeping one line open forever&lt;/td&gt;
&lt;td&gt;nginx's resolver opens &lt;strong&gt;one long-lived UDP socket&lt;/strong&gt; and reuses it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reaching Priya, not the switchboard&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Cilium&lt;/strong&gt;'s socket load-balancer rewrites the connection to one &lt;em&gt;specific&lt;/em&gt; CoreDNS &lt;strong&gt;pod&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the building reorganisation&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Karpenter&lt;/strong&gt; replacing nodes, which replaces CoreDNS pods — about daily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;silence vs. "disconnected"&lt;/td&gt;
&lt;td&gt;a DNS &lt;strong&gt;timeout&lt;/strong&gt; vs. a socket &lt;strong&gt;error&lt;/strong&gt;. nginx reopens on error, &lt;em&gt;never&lt;/em&gt; on timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  If you haven't met these three before
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;CoreDNS&lt;/strong&gt; is the cluster's phone book. In Kubernetes you don't address a pod by IP — you address a &lt;em&gt;Service&lt;/em&gt; by name, like &lt;code&gt;iamplatform.my-ns.svc.cluster.local&lt;/code&gt;. CoreDNS is the (usually two-pod) deployment that turns those names into IPs, and essentially everything in the cluster asks it, constantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cilium&lt;/strong&gt; is the networking layer. It does load-balancing inside the kernel using eBPF, instead of the older &lt;code&gt;kube-proxy&lt;/code&gt;/iptables approach. The detail that matters here: when your app calls &lt;code&gt;connect()&lt;/code&gt; on a Service IP, Cilium rewrites the destination &lt;em&gt;there and then&lt;/em&gt; to one specific backend pod's IP. That's part of what makes it fast — and it means your socket is attached to that one pod, not to the Service as a whole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Karpenter&lt;/strong&gt; is the node autoscaler. It adds nodes when pods need room and — the part that matters here — removes under-used ones to save money. Pods on a removed node get rescheduled elsewhere, CoreDNS pods included. Completely routine behaviour.&lt;/p&gt;

&lt;p&gt;Three reasonable components, each doing its job, combining into a permanent black hole.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbaeq1bltg071y58l5zgd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbaeq1bltg071y58l5zgd.png" alt="One socket, glued to one pod. When Karpenter replaces that pod, the socket keeps pointing at nothing." width="800" height="495"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One socket, glued to one pod. When Karpenter replaces that pod, the socket keeps pointing at nothing.&lt;/p&gt;

&lt;p&gt;DNS lookup times out → nginx can't find the backend → 502 → our error handler turns that into the 503 the user sees. The backend was never even contacted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it looked random
&lt;/h2&gt;

&lt;p&gt;Each nginx worker has its own socket, so only the worker with the dead socket should fail — maybe 1/8 of traffic. We were seeing far more than that.&lt;/p&gt;

&lt;p&gt;The reason was one line of config: &lt;code&gt;accept_mutex off&lt;/code&gt; with no &lt;code&gt;reuseport&lt;/code&gt;. Without &lt;code&gt;reuseport&lt;/code&gt;, workers race to accept new connections and one worker reliably wins most of them. If that greedy worker is the one holding the dead socket, &lt;strong&gt;one broken worker is effectively the whole pod.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0y5lceuddf8j0og46itw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0y5lceuddf8j0og46itw.png" alt="Without reuseport one worker wins most connections — so one dead socket looks like a dead pod." width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Without reuseport one worker wins most connections — so one dead socket looks like a dead pod.&lt;/p&gt;

&lt;p&gt;That also explains the "intermittent" feel: whether a pod looked healthy or dead depended on &lt;em&gt;which&lt;/em&gt; worker happened to be the greedy one.&lt;/p&gt;

&lt;h2&gt;
  
  
  We proved it instead of guessing
&lt;/h2&gt;

&lt;p&gt;The temptation here is to ship a plausible fix and hope. Instead we wrote down predictions first — which pods would break, which worker in each, and roughly what failure rate — then &lt;strong&gt;deliberately deleted a live CoreDNS pod&lt;/strong&gt; and checked.&lt;/p&gt;

&lt;p&gt;The predictions matched. That mattered, because the first proposed fix (tune kernel socket settings, add more replicas) targeted a completely different theory — port exhaustion — that the evidence ruled out. Without the experiment we'd have shipped it and stayed broken.&lt;/p&gt;

&lt;p&gt;Reading the sockets needs no special tooling:&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="c"&gt;# which CoreDNS pod is each worker's DNS socket glued to?&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/net/udp     &lt;span class="c"&gt;# decode the hex peer address column&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fix: three layers
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpo9djkhn35kieff7ncgc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpo9djkhn35kieff7ncgc.png" alt="Failure rate during a deliberate CoreDNS pod deletion, at each layer of the fix." width="800" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Failure rate during a deliberate CoreDNS pod deletion, at each layer of the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Spread the traffic&lt;/strong&gt; — &lt;code&gt;listen 8080 reuseport&lt;/code&gt;. The kernel deals new connections evenly across workers, so a broken worker costs 1/N of a pod instead of all of it. This caps the damage; it doesn't prevent it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Keep a spare line&lt;/strong&gt; — list the same DNS name &lt;em&gt;twice&lt;/em&gt; in the &lt;code&gt;resolver&lt;/code&gt; directive. nginx opens one socket per listed address, so two entries means two independent sockets landing on (probably) two different CoreDNS pods. You must also raise &lt;code&gt;resolver_timeout&lt;/code&gt; above nginx's hard-coded ~5s retry interval, or it never gets around to trying the second socket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;resolver&lt;/span&gt;  &lt;span class="s"&gt;kube-dns.kube-system.svc.cluster.local&lt;/span&gt;
          &lt;span class="s"&gt;kube-dns.kube-system.svc.cluster.local&lt;/span&gt;  &lt;span class="s"&gt;valid=60s&lt;/span&gt; &lt;span class="s"&gt;ipv6=off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;resolver_timeout&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# was 3s — too short to ever reach socket #2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Honest cost: when the first socket is dead, requests wait ~5 seconds before the retry succeeds. That's a real, user-visible stall. This layer &lt;em&gt;masks&lt;/em&gt; the problem, it doesn't heal it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Take DNS off the request path entirely&lt;/strong&gt; — the actual cure. Instead of looking up the address while a request waits, declare each backend as an upstream group that nginx resolves &lt;strong&gt;in the background&lt;/strong&gt; and pools connections to (nginx 1.27.3+ in OSS; pooling explained two sections down, if the term is new):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;mt_iamplatform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;zone&lt;/span&gt;   &lt;span class="s"&gt;mt_upstreams&lt;/span&gt; &lt;span class="mi"&gt;2m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;iamplatform.my-ns.svc.cluster.local&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;22036&lt;/span&gt; &lt;span class="s"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# background refresh&lt;/span&gt;
  &lt;span class="kn"&gt;keepalive&lt;/span&gt; &lt;span class="mi"&gt;32&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 magic property: if a background refresh times out, nginx &lt;strong&gt;keeps the last known good address&lt;/strong&gt; instead of failing the request. A DNS outage stops being an outage.&lt;/p&gt;

&lt;h3&gt;
  
  
  The numbers
&lt;/h3&gt;

&lt;p&gt;Same experiment each time — delete a live CoreDNS pod:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Requests that failed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~37%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ spread + spare socket&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~3.4%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ DNS off the request path&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0&lt;/strong&gt; of 21,800&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the last run a worker was &lt;em&gt;still&lt;/em&gt; holding a dead DNS socket the whole time. Nobody noticed, which is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule we refused to break: nginx must always boot
&lt;/h2&gt;

&lt;p&gt;Worth a detour, because it's the reason the fix looks the way it does.&lt;/p&gt;

&lt;p&gt;If you name a backend literally — &lt;code&gt;proxy_pass http://iamplatform.my-ns.svc.cluster.local:22036;&lt;/code&gt;, or a plain &lt;code&gt;server&lt;/code&gt; line inside an &lt;code&gt;upstream&lt;/code&gt; — nginx resolves that name &lt;strong&gt;once, at config load&lt;/strong&gt;. Efficient. But if &lt;em&gt;any&lt;/em&gt; one of those names fails to resolve — a service not created yet, a namespace being rebuilt, a typo in a new route — nginx prints &lt;code&gt;[emerg] host not found in upstream&lt;/code&gt; and &lt;strong&gt;refuses to start at all&lt;/strong&gt;. One missing backend takes down all ~50 routes, including the 49 that were fine.&lt;/p&gt;

&lt;p&gt;So our routes deliberately use a &lt;strong&gt;variable&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;set&lt;/span&gt;        &lt;span class="nv"&gt;$backend&lt;/span&gt; &lt;span class="s"&gt;"iamplatform.my-ns.svc.cluster.local:22036"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://&lt;/span&gt;&lt;span class="nv"&gt;$backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;# a variable -&amp;gt; resolved per request&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;nginx can't resolve a variable at config load, so it doesn't try. The gateway boots knowing nothing about its backends and looks each one up at request time. A missing service now returns 502 on &lt;strong&gt;its own route&lt;/strong&gt;, and nothing else notices.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fei2j58r2uw15zpo44n09.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fei2j58r2uw15zpo44n09.png" alt="A literal hostname makes every route depend on every backend at boot. A variable keeps the failure local." width="799" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A literal hostname makes every route depend on every backend at boot. A variable keeps the failure local.&lt;/p&gt;

&lt;p&gt;Layer 3 had to preserve that. The obvious way to add pooling is a hard-coded &lt;code&gt;upstream&lt;/code&gt; block — which hands straight back the boot-time dependency we'd avoided. Two details kept it safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;server ... resolve&lt;/code&gt;&lt;/strong&gt; tells nginx to skip the config-load lookup entirely and resolve in the background instead, so an unresolvable backend still can't block startup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;$mt_backend&lt;/code&gt; holds the upstream group's name, not a hostname&lt;/strong&gt; — so &lt;code&gt;proxy_pass&lt;/code&gt; is still pointed at a variable and never quietly became a literal.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;set&lt;/span&gt;        &lt;span class="nv"&gt;$backend&lt;/span&gt; &lt;span class="s"&gt;"mt_iamplatform"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# the group's name, not a host&lt;/span&gt;
&lt;span class="k"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://&lt;/span&gt;&lt;span class="nv"&gt;$backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;# this line never changed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A gotcha if you try this: nginx only matches a variable &lt;code&gt;proxy_pass&lt;/code&gt; to an upstream group when the value contains &lt;strong&gt;no port&lt;/strong&gt;. &lt;code&gt;mt_iamplatform&lt;/code&gt; resolves to the group; &lt;code&gt;mt_iamplatform:22036&lt;/code&gt; is treated as a hostname and fails.&lt;/p&gt;

&lt;p&gt;So we gained pooling and background DNS &lt;em&gt;and&lt;/em&gt; kept "a broken backend is that backend's problem."&lt;/p&gt;

&lt;h2&gt;
  
  
  Connection pooling, in 60 seconds
&lt;/h2&gt;

&lt;p&gt;Both the fix above and the bug below turn on this, so here's the short version.&lt;/p&gt;

&lt;p&gt;By default, when nginx proxies a request it opens a fresh TCP connection to the backend, sends the request, reads the response, and closes it. Every request pays a three-way handshake, and every close parks a socket in &lt;code&gt;TIME_WAIT&lt;/code&gt; on nginx for 60 seconds, holding one of ~28,000 ephemeral ports. At a few hundred requests a second, that adds up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pooling&lt;/strong&gt; — nginx calls it upstream keep-alive — means: don't close it. Keep the connection and let the next request to the same backend ride the line that's already open.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm56g8veiu72clsdox2yv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm56g8veiu72clsdox2yv.png" alt="Without pooling, every request pays a handshake and leaves a socket in TIME_WAIT. With it, one connection carries many requests." width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Without pooling, every request pays a handshake and leaves a socket in TIME_WAIT. With it, one connection carries many requests.&lt;/p&gt;

&lt;p&gt;Switching it on is three things, not one, and missing any of them leaves you with a pool that silently never engages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;my_backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;backend.my-ns.svc.cluster.local&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;# how many idle connections to cache&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive_requests&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# reuse one this many times, then close it&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive_timeout&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;# close it after this long idle&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/thing&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://my_backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;# keep-alive needs HTTP/1.1&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# don't forward the client's "Connection: close"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things that catch people out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;keepalive 32&lt;/code&gt; is per worker process&lt;/strong&gt;, not per pod. Sixteen workers means up to 512 idle connections from a single pod.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need a named &lt;code&gt;upstream&lt;/code&gt; block.&lt;/strong&gt; A bare &lt;code&gt;proxy_pass http://host:port;&lt;/code&gt; cannot pool at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whoever closes first matters.&lt;/strong&gt; Your &lt;code&gt;keepalive_timeout&lt;/code&gt; has to sit &lt;em&gt;below&lt;/em&gt; the backend's own idle timeout, or you'll eventually pick up a connection just as the backend's &lt;code&gt;FIN&lt;/code&gt; arrives — &lt;code&gt;upstream prematurely closed connection&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nginx only pools a connection whose response it finished reading.&lt;/strong&gt; That one is a trapdoor, and it's the one we fell through.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Checking which you've got needs no extra tooling — count sockets to the backend from inside the pod:&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="c"&gt;# column 4 is the TCP state in hex: 01 = ESTABLISHED, 06 = TIME_WAIT&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$4=="01"'&lt;/span&gt; /proc/net/tcp | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$4=="06"'&lt;/span&gt; /proc/net/tcp | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A healthy pool is a steady handful of &lt;code&gt;ESTABLISHED&lt;/code&gt; and almost nothing in &lt;code&gt;TIME_WAIT&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bonus bug: a pool that never pooled
&lt;/h2&gt;

&lt;p&gt;Measuring after a fix is how you find the next one. With DNS off the request path we looked at the gateway's sockets again: &lt;strong&gt;~13,000 &lt;code&gt;TIME_WAIT&lt;/code&gt; sockets per pod and zero pooled connections&lt;/strong&gt; to our auth service — even though the config had declared &lt;code&gt;keepalive 64&lt;/code&gt; for it for years.&lt;/p&gt;

&lt;p&gt;Back to the clerk. Before serving anyone, it also has to ask the auth desk &lt;em&gt;"is this person allowed in?"&lt;/em&gt; The clerk only needs the answer written on the envelope — yes/no, and who you are. But the auth desk kept stuffing a full brochure inside every reply. And the clerk has a rule: &lt;strong&gt;I can only keep the phone line open if the reply is envelope-only.&lt;/strong&gt; So it hung up after every call and redialled for the next customer.&lt;/p&gt;

&lt;p&gt;In nginx terms: &lt;code&gt;auth_request&lt;/code&gt; marks its subrequest &lt;strong&gt;header-only&lt;/strong&gt; and never reads the response body — and nginx returns the connection to its keep-alive pool &lt;em&gt;only&lt;/em&gt; when the response has no body (&lt;code&gt;204&lt;/code&gt;, or a non-chunked &lt;code&gt;Content-Length: 0&lt;/code&gt;). Our auth service answered with JSON, so the pool could never engage. Not just untidy: at ~470 connections per second to one backend pod you run out of ephemeral ports, and during an auth-service rollout — when there may be only one pod left — a busy gateway sits uncomfortably close to that.&lt;/p&gt;

&lt;p&gt;The fix belonged in the auth service. But that service has its own ingress and other consumers who genuinely want the body, so stripping it for everyone would break them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tag the caller, not the endpoint
&lt;/h3&gt;

&lt;p&gt;The gateway already stamps every auth subrequest with a header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Sent-From&lt;/span&gt; &lt;span class="s"&gt;"nginx-pods"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the auth service now drops &lt;strong&gt;only the body&lt;/strong&gt; — never the status code, never the headers — for requests carrying that tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// middlewares.HeaderOnlyForGateway, wired on POST /auth&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EqualFold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Sent-From"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="s"&gt;"nginx-pods"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c"&gt;// every other caller: unchanged, body and all&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;headerOnlyWriter&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;// keep status + headers,&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                                &lt;span class="c"&gt;// discard the body&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is lost, because the gateway reads only the response &lt;em&gt;headers&lt;/em&gt; (&lt;code&gt;X-Identity-*&lt;/code&gt;, &lt;code&gt;X-Session-ID&lt;/code&gt;, &lt;code&gt;X-Auth-Error-*&lt;/code&gt;) and builds its own client-facing JSON from them. Handlers keep calling &lt;code&gt;c.JSON(...)&lt;/code&gt; exactly as before — they don't know the middleware exists.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fystrxaeeo3lqlq14i8t8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fystrxaeeo3lqlq14i8t8.png" alt="A header tags the caller, so one shared endpoint can answer the gateway and everyone else differently." width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A header tags the caller, so one shared endpoint can answer the gateway and everyone else differently.&lt;/p&gt;

&lt;p&gt;One easy-to-miss detail: once pooling works, the two sides' idle timeouts must be &lt;strong&gt;ordered&lt;/strong&gt;. Go's &lt;code&gt;IdleTimeout&lt;/code&gt; is set to 120s, deliberately &lt;em&gt;above&lt;/em&gt; the gateway's &lt;code&gt;keepalive_timeout 60s&lt;/code&gt;, so nginx is always the side that closes an idle connection. If the server closed first, the gateway would sometimes pick one up just as its FIN arrived — surfacing as &lt;code&gt;upstream prematurely closed connection&lt;/code&gt;.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A long-lived connection is a cached decision.&lt;/strong&gt; Anything you connect to &lt;em&gt;once&lt;/em&gt; and keep — DNS sockets, pooled connections, resolved IPs — is a bet that the thing on the other end won't be replaced. In Kubernetes, it will be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeout ≠ error.&lt;/strong&gt; Many clients recover from a refused connection and hang forever on silence. Ask which one your retry logic keys on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write your predictions down before you break something.&lt;/strong&gt; It's the cheapest way to tell a real diagnosis from a plausible story — and it's what stopped us shipping the wrong fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail small, not early.&lt;/strong&gt; Resolving every dependency up front feels safer, but it couples every route to every backend. Late binding — looking things up when you actually need them — is what keeps one missing service from becoming a full outage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;The gateway is nginx on Kubernetes with Cilium and Karpenter, but the shape of this bug isn't nginx-specific. Any client that holds one socket to one replica of a service has it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>nginx</category>
      <category>devops</category>
      <category>networking</category>
    </item>
    <item>
      <title>A Practical Guide to Using Auth0 as an Identity Broker for Epic SMART on FHIR</title>
      <dc:creator>Akarshan Gandotra</dc:creator>
      <pubDate>Mon, 03 Aug 2026 11:30:36 +0000</pubDate>
      <link>https://dev.to/akarshan/a-practical-guide-to-using-auth0-as-an-identity-broker-for-epic-smart-on-fhir-416b</link>
      <guid>https://dev.to/akarshan/a-practical-guide-to-using-auth0-as-an-identity-broker-for-epic-smart-on-fhir-416b</guid>
      <description>&lt;p&gt;Your team already uses Auth0. Now someone wants to add Epic. On paper, it looks like one more enterprise connection — Epic speaks OAuth 2.0 and OpenID Connect, Auth0 brokers OIDC connections all day, so this should be a config change, not a project.&lt;/p&gt;

&lt;p&gt;It isn't, and that has nothing to do with Epic being difficult. SMART on FHIR isn't just a login — it's a login wrapped around a second, quieter handoff: which FHIR server to talk to, which patient or encounter the user was looking at, and which scopes let your app read any of it. Auth0 has no idea any of that exists. Configure the connection the way you would for any other enterprise IdP, and the login will work perfectly in the demo, then quietly drop the one piece of context your application actually needed.&lt;/p&gt;

&lt;p&gt;This guide is for teams in exactly that spot: Auth0 is already the broker, Epic is the new connection, and the question is how to get through that hop without losing what SMART on FHIR needs to carry.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Auth0 can broker Epic SMART on FHIR logins, but only if you stop treating it as a single login and start treating it as three separate relationships: app↔Auth0, Auth0↔Epic, and (if you call FHIR APIs) Epic-token↔FHIR API. The parts that actually break in practice: &lt;code&gt;iss&lt;/code&gt;/&lt;code&gt;aud&lt;/code&gt;/&lt;code&gt;launch&lt;/code&gt; not surviving the trip through Auth0 unchanged, Epic's SMART discovery document not being the same thing as Auth0's OIDC auto-discovery, and the Epic access token never reaching your backend unless you go get it deliberately.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The architecture looks simple on a whiteboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application → Auth0 → Epic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application integrates with Auth0, and Auth0 handles authentication with Epic through an OIDC enterprise connection. That works well, especially when your platform already supports multiple identity providers through Auth0 — but only once the SMART-specific pieces below are made explicit instead of assumed to ride along for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding the authentication flow
&lt;/h2&gt;

&lt;p&gt;A brokered login is really three relationships stacked on top of each other:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Application ↔ Auth0&lt;/strong&gt; — the login relationship your app already knows how to build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth0 ↔ Epic&lt;/strong&gt; — the enterprise connection Auth0 brokers on your app's behalf.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Epic access token ↔ Epic FHIR API&lt;/strong&gt; — only relevant if your application calls FHIR APIs directly, not just authenticates users.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping these three boundaries separate is the most important design decision in this whole integration. Concretely, for an EHR launch, the hops look like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Hop&lt;/th&gt;
&lt;th&gt;What happens&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;User → Application&lt;/td&gt;
&lt;td&gt;EHR launch lands on your app with &lt;code&gt;iss&lt;/code&gt; and &lt;code&gt;launch&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Application → Auth0&lt;/td&gt;
&lt;td&gt;Redirect to Auth0's &lt;code&gt;/authorize&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Auth0 → Epic&lt;/td&gt;
&lt;td&gt;Redirect to Epic's &lt;code&gt;/authorize&lt;/code&gt; — the upstream leg of the enterprise connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;User ↔ Epic&lt;/td&gt;
&lt;td&gt;User authenticates (often silent — they're already inside the EHR)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Epic → Auth0&lt;/td&gt;
&lt;td&gt;Redirect to Auth0's callback with an authorization code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Auth0 ↔ Epic&lt;/td&gt;
&lt;td&gt;Back-channel code exchange, server-to-server, using Auth0's own client credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Auth0 → Application&lt;/td&gt;
&lt;td&gt;Redirect to your app's callback with Auth0's &lt;em&gt;own&lt;/em&gt; code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Application ↔ Auth0&lt;/td&gt;
&lt;td&gt;Your app exchanges that code for its own tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By default, your application only ever sees the result of step 8. Steps 5–6 happen entirely inside Auth0, unless you deliberately configure a way to retrieve Epic's token afterward — more on that later, in &lt;em&gt;Plan how the backend will obtain the Epic token&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start by defining the purpose of the integration
&lt;/h2&gt;

&lt;p&gt;Before you touch the Auth0 dashboard, decide what the application actually needs from Epic — this one decision determines almost everything else in this guide.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using Epic only for authentication
&lt;/h3&gt;

&lt;p&gt;In the simpler model, Epic is used only to identify the user. The application may need claims such as &lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, and &lt;code&gt;fhirUser&lt;/code&gt;. Once the user has authenticated, Auth0 issues its own tokens and the application creates its own session. The application never touches the Epic access token.&lt;/p&gt;

&lt;p&gt;This is the easiest model to support through an Auth0 enterprise connection, and if it's all you need, most of the friction in this guide disappears.&lt;/p&gt;

&lt;h3&gt;
  
  
  Calling Epic FHIR APIs
&lt;/h3&gt;

&lt;p&gt;The second model is more involved. The application needs to authenticate the user, obtain an Epic access token, preserve SMART launch context, request appropriate FHIR scopes, use the Epic token against the correct FHIR server, and potentially refresh or renew that token.&lt;/p&gt;

&lt;p&gt;Here, the Auth0 token and the Epic token serve different purposes and are not interchangeable. An Auth0 access token is generally intended for your own application APIs. An Epic access token is intended for Epic FHIR APIs. Mixing them up is the single most common design mistake in this kind of integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Register the callback correctly in Epic
&lt;/h2&gt;

&lt;p&gt;When Auth0 acts as the broker, Epic redirects the authorization response back to Auth0, not directly to your application. The callback registered in Epic follows this exact pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://&amp;lt;auth0-domain&amp;gt;/login/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's Auth0's fixed callback path for enterprise connections, regardless of which upstream IdP you're connecting — &lt;code&gt;https://&amp;lt;tenant&amp;gt;.us.auth0.com/login/callback&lt;/code&gt; on a standard domain, or &lt;code&gt;https://&amp;lt;your-custom-domain&amp;gt;/login/callback&lt;/code&gt; with a custom Auth0 domain.&lt;/p&gt;

&lt;p&gt;Your application has a separate callback configured in Auth0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://&amp;lt;application-domain&amp;gt;/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Registering your application's callback directly in Epic will not work here — Epic has no way to reach your application. It only knows about Auth0.&lt;/p&gt;

&lt;p&gt;Before testing, verify that the callback registered in Epic exactly matches the one Auth0 generates: protocol, domain, path, trailing slash, and custom Auth0 domain if you use one. OAuth callback matching is generally exact, and this is the single fastest way to lose an afternoon to a one-character mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configure Epic as an OIDC enterprise connection
&lt;/h2&gt;

&lt;p&gt;Epic can be added to Auth0 as an OpenID Connect enterprise connection. A typical configuration needs an issuer, authorization endpoint, token endpoint, JWKS endpoint, client ID, client secret, scopes, and connection type.&lt;/p&gt;

&lt;p&gt;Use discovery metadata instead of guessing endpoint URLs — but be precise about &lt;em&gt;which&lt;/em&gt; discovery document you're reading. Epic's FHIR servers publish SMART-specific metadata at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{fhir-base-url}/.well-known/smart-configuration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(available on Epic releases from roughly August 2021 onward — older instances may only expose the FHIR &lt;code&gt;CapabilityStatement&lt;/code&gt; at &lt;code&gt;{fhir-base-url}/metadata&lt;/code&gt;). It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://&amp;lt;epic-host&amp;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;"authorization_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://&amp;lt;epic-host&amp;gt;/.../authorize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://&amp;lt;epic-host&amp;gt;/.../token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jwks_uri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://&amp;lt;epic-host&amp;gt;/.../jwks"&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;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; this is not automatically the same document as the generic OIDC discovery Auth0 expects when you point its connection setup at an issuer for auto-discovery — &lt;code&gt;{issuer}/.well-known/openid-configuration&lt;/code&gt;. Auth0's "fetch from issuer" convenience option quietly failing against a raw Epic FHIR base URL is one of the most common first-hour surprises. Plan to fetch the SMART configuration document yourself and paste &lt;code&gt;authorization_endpoint&lt;/code&gt;, &lt;code&gt;token_endpoint&lt;/code&gt;, and &lt;code&gt;jwks_uri&lt;/code&gt; into Auth0's manual fields.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Different Epic environments use different issuers, OAuth endpoints, FHIR base URLs, signing keys, and client registrations — and each Epic app record needs the relevant SMART capabilities (&lt;code&gt;launch-ehr&lt;/code&gt;, &lt;code&gt;client-confidential-symmetric&lt;/code&gt;, &lt;code&gt;sso-openid-connect&lt;/code&gt;, and so on) enabled for the flow you're building. A working sandbox configuration tells you almost nothing about whether a hospital's production Epic instance will behave the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the authorization-code flow
&lt;/h2&gt;

&lt;p&gt;Configure the Auth0 enterprise connection to use the back-channel authorization-code flow. This isn't just a conceptual choice — it's a literal &lt;strong&gt;Type&lt;/strong&gt; setting on the connection (Auth0 Dashboard → Authentication → Enterprise → your OIDC connection), toggled between &lt;strong&gt;Front Channel&lt;/strong&gt; and &lt;strong&gt;Back Channel&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"strategy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oidc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"options"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"back_channel"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back-channel makes Auth0 send an upstream request with &lt;code&gt;response_type=code&lt;/code&gt;, receive the code from Epic, and exchange it at Epic's token endpoint using Auth0's own client credentials.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; PKCE, if you use it, protects only the browser-to-Auth0 leg. It does not extend to the Auth0-to-Epic exchange — that hop is authenticated with Auth0's client secret, not your user's proof-of-possession.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A front-channel configuration instead generates a request with &lt;code&gt;response_type=id_token&lt;/code&gt; and &lt;code&gt;response_mode=form_post&lt;/code&gt; — closer to an implicit flow, where Epic returns tokens directly in the browser response instead of a code Auth0 exchanges server-side. That's not what a SMART application built around Authorization Code Flow expects.&lt;/p&gt;

&lt;p&gt;When validating the setup, inspect the actual Epic authorization URL your browser is redirected to and confirm &lt;code&gt;response_type=code&lt;/code&gt; is present. Don't trust the dashboard setting alone — the generated browser request is the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enable the connection for the Auth0 application
&lt;/h2&gt;

&lt;p&gt;Creating an enterprise connection doesn't automatically make it available to every Auth0 application. After creating the Epic connection, enable it for the specific application that will initiate the login.&lt;/p&gt;

&lt;p&gt;With Auth0 Organizations in the picture, the connection may also need to be enabled separately for the relevant organization. That's up to four layers, all of which have to be valid before login completes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Connection exists&lt;/li&gt;
&lt;li&gt;Connection enabled for application&lt;/li&gt;
&lt;li&gt;Connection enabled for organization&lt;/li&gt;
&lt;li&gt;User allowed in organization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Miss any one of these and the failure mode looks identical from the outside: the user just can't log in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configure the required SMART scopes
&lt;/h2&gt;

&lt;p&gt;A standard OIDC login might request &lt;code&gt;openid profile email&lt;/code&gt;. SMART applications need more. A common starting point for an Epic EHR-launched application is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openid fhirUser launch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each scope earns its place.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;openid&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Enables OpenID Connect and allows an ID token to be returned.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;fhirUser&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Requests the FHIR identity associated with the authenticated user — a reference such as &lt;code&gt;Practitioner/123&lt;/code&gt;, &lt;code&gt;Patient/456&lt;/code&gt;, or &lt;code&gt;RelatedPerson/789&lt;/code&gt;. This is usually more useful than email for linking the authenticated identity to a healthcare user, and it's the scope most teams forget until they need it.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;launch&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Signals that the application expects EHR launch context. The actual launch value travels separately, as &lt;code&gt;launch=&amp;lt;opaque-launch-value&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;profile&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Still worth including when you need standard profile claims, but it does not replace &lt;code&gt;fhirUser&lt;/code&gt; — they answer different questions.&lt;/p&gt;

&lt;p&gt;A reasonable starting configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openid profile fhirUser launch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final scope list depends on the FHIR resources the application actually needs — &lt;code&gt;patient/Patient.read&lt;/code&gt;, &lt;code&gt;patient/Observation.read&lt;/code&gt;, &lt;code&gt;user/Practitioner.read&lt;/code&gt;, and so on. Request the minimum that lets the app do its job; every extra scope is one more thing to justify during Epic's app review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve the SMART launch parameters
&lt;/h2&gt;

&lt;p&gt;An EHR-launched SMART application is normally opened with parameters like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;iss=https://epic.example.com/api/FHIR/R4
launch=opaque-value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat &lt;code&gt;launch&lt;/code&gt; as opaque. Don't decode it, modify it, generate a replacement, or assume anything about its internal format — store it temporarily and forward it as-is. &lt;code&gt;iss&lt;/code&gt; represents the FHIR server that initiated the launch.&lt;/p&gt;

&lt;p&gt;A simplified request flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /smart/launch?iss=https://epic.example.com/api/FHIR/R4&amp;amp;launch=opaque-value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application then starts authorization with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scope=openid fhirUser launch
aud=https://epic.example.com/api/FHIR/R4
launch=opaque-value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mapping that matters is &lt;code&gt;aud = iss received during the SMART launch&lt;/code&gt;. That value is not the Auth0 audience, the Epic client ID, the Auth0 client ID, the OIDC issuer, or the application callback URL — it's specifically the FHIR resource server the application wants to access, and it's worth saying that plainly because it's the single most common source of confused config in this whole flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate the incoming &lt;code&gt;iss&lt;/code&gt; value
&lt;/h2&gt;

&lt;p&gt;In a multi-tenant healthcare platform, don't trust the &lt;code&gt;iss&lt;/code&gt; parameter just because it showed up in the browser — it arrives via an unauthenticated redirect, so treat it like any other user-controllable input. Validate it against a configured list of supported Epic environments:&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;SUPPORTED_EPIC_ISSUERS&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;https://hospital-a.example.com/api/FHIR/R4&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;https://hospital-b.example.com/api/FHIR/R4&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before starting the authorization flow:&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;iss&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SUPPORTED_EPIC_ISSUERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;InvalidIssuerError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip this check and you've built an open redirector that will happily start an authorization flow against whatever FHIR server shows up in a query string — including one an attacker controls. For a larger platform, store the trusted configuration per tenant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tenant A → Epic issuer A, Epic client ID A, Epic connection A
Tenant B → Epic issuer B, Epic client ID B, Epic connection B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resolve the tenant first, from something you trust, then validate the Epic issuer against &lt;em&gt;that&lt;/em&gt; tenant's configuration — never derive the tenant only from an untrusted browser parameter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate EHR launch from standalone launch
&lt;/h2&gt;

&lt;p&gt;SMART supports multiple launch styles. Don't treat them as the same flow wearing different clothes.&lt;/p&gt;

&lt;h3&gt;
  
  
  EHR launch
&lt;/h3&gt;

&lt;p&gt;The application is launched from within Epic. It receives &lt;code&gt;iss&lt;/code&gt; and &lt;code&gt;launch&lt;/code&gt;, and requests the &lt;code&gt;launch&lt;/code&gt;, &lt;code&gt;openid&lt;/code&gt;, and &lt;code&gt;fhirUser&lt;/code&gt; scopes. The launch context may include &lt;code&gt;patient&lt;/code&gt;, &lt;code&gt;encounter&lt;/code&gt;, &lt;code&gt;tenant&lt;/code&gt;, &lt;code&gt;need_patient_banner&lt;/code&gt;, and &lt;code&gt;smart_style_url&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Standalone launch
&lt;/h3&gt;

&lt;p&gt;The application starts outside the EHR. There's no incoming &lt;code&gt;launch&lt;/code&gt; value. Instead, the application requests context through scopes like &lt;code&gt;launch/patient&lt;/code&gt; and &lt;code&gt;launch/encounter&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Give these two flows separate entry points — &lt;code&gt;/smart/launch&lt;/code&gt; for EHR launch, &lt;code&gt;/login/epic&lt;/code&gt; for standalone — rather than one endpoint branching on whether a &lt;code&gt;launch&lt;/code&gt; parameter happens to be present. It's easy for EHR-specific assumptions to leak into the standalone path when they share code, and hard to notice until a standalone user hits an edge case nobody tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass the correct parameters upstream
&lt;/h2&gt;

&lt;p&gt;Auth0 sits between the application and Epic, so whatever it receives has to actually reach Epic's authorization endpoint — &lt;code&gt;aud&lt;/code&gt;, &lt;code&gt;launch&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;, and &lt;code&gt;login_hint&lt;/code&gt; are the ones that matter most, and whether they pass through dynamically depends entirely on how the enterprise connection is configured. Test this early; it's not something you want to discover during a hospital pilot.&lt;/p&gt;

&lt;p&gt;Capture the authorization request Auth0 actually sends to Epic and confirm it contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client_id=&amp;lt;epic-client-id&amp;gt;
redirect_uri=https://&amp;lt;auth0-domain&amp;gt;/login/callback
response_type=code
scope=openid fhirUser launch
aud=&amp;lt;epic-fhir-base-url&amp;gt;
launch=&amp;lt;original-launch-value&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;aud&lt;/code&gt; or &lt;code&gt;launch&lt;/code&gt; is missing, the user can still reach Epic's login page and authenticate successfully — the resulting session just won't contain the SMART context your application needs. A successful login is not the bar here. The final token response has to contain the context, not just a valid identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use &lt;code&gt;state&lt;/code&gt; to correlate the complete flow
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;state&lt;/code&gt; does two jobs: protecting the authorization flow from CSRF, and correlating the returning response with the original launch. For a SMART integration, that means associating the attempt with the tenant, the Epic issuer, the launch value, the original application URL, the Auth0 organization, and the requested connection.&lt;/p&gt;

&lt;p&gt;Don't put any of that directly into a browser-visible state string. Generate a random value, store the metadata server-side against it, and send only the random value in the authorization request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"random-value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tenant-a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"epic_issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://hospital-a.example.com/api/FHIR/R4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"launch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"opaque-value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"return_to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/patient-chart"&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;The browser only ever sees &lt;code&gt;state=random-value&lt;/code&gt;. On callback, the application looks up the stored metadata and validates it. That state entry should expire quickly, be single-use, be bound to the browser session where possible, and be deleted the moment the callback finishes processing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understand how Auth0 Organizations affect login
&lt;/h2&gt;

&lt;p&gt;Organizations add an authorization layer &lt;em&gt;after&lt;/em&gt; Epic authentication. A user can authenticate with Epic perfectly and still get rejected by Auth0 because they're not permitted in the requested organization.&lt;/p&gt;

&lt;p&gt;Before relying on an organization-based login, verify: the organization exists, the Epic connection is enabled for it, the connection is enabled for the Auth0 application, the user can become a member (with automatic membership configured if that's the intent), and the application is passing the correct organization ID.&lt;/p&gt;

&lt;p&gt;Don't assume every &lt;code&gt;access_denied&lt;/code&gt; is Epic saying no — check where in the flow it actually happened. An error that surfaces &lt;em&gt;after&lt;/em&gt; the browser has already returned from Epic to Auth0 is usually about organization membership, an Auth0 Action, a connection policy, or user linking, not Epic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Recognize this exact message:&lt;/strong&gt; if every connection enabled for an organization is an enterprise connection, and all of them are hidden from the login prompt, Auth0 returns &lt;em&gt;"no connections enabled for the organization are visible."&lt;/em&gt; That's a pure Organizations misconfiguration. Don't spend an afternoon staring at Epic's side of the fence looking for a bug that isn't there.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Decide how users will be linked
&lt;/h2&gt;

&lt;p&gt;Epic's identity may not map cleanly onto identities you already have. Candidates include the Epic OIDC &lt;code&gt;sub&lt;/code&gt;, a FHIR user reference, email, employee ID, practitioner ID, or some existing external-identity mapping.&lt;/p&gt;

&lt;p&gt;Don't link accounts by email alone unless your security model explicitly allows it. Email changes, goes missing, differs in casing, gets shared, and often doesn't match between Epic and the enterprise directory anyway.&lt;/p&gt;

&lt;p&gt;A composite key holds up better:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tenant-a + epic + oidc-subject-123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you a stable external identity key. Store &lt;code&gt;fhirUser&lt;/code&gt; as related healthcare identity metadata alongside it, not as the primary login identifier — it answers "which FHIR resource is this person," not "which account is this."&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the Auth0 token and Epic token separate
&lt;/h2&gt;

&lt;p&gt;After the brokered flow, you may be holding an Epic ID token, Epic access token, and Epic refresh token; an Auth0 ID token and Auth0 access token; and your own application session token. Each has a different issuer, audience, and job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth0 ID token&lt;/strong&gt; — tells the application who the authenticated Auth0 user is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth0 access token&lt;/strong&gt; — calls an API protected by Auth0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Epic access token&lt;/strong&gt; — calls Epic FHIR APIs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Validate issuer, audience, expiry, scope, and intended API before using any of them. Never send the Auth0 access token to the Epic FHIR API. Never send the Epic access token to an internal API expecting an Auth0 token. Your APIs should reject tokens minted for a different audience — don't rely on discipline alone to keep these apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plan how the backend will obtain the Epic token
&lt;/h2&gt;

&lt;p&gt;Here's the part that surprises people: when Auth0 performs the upstream code exchange, &lt;em&gt;Auth0&lt;/em&gt; receives the Epic access token — not your application. Getting it from there to your backend takes a deliberate decision, not a default.&lt;/p&gt;

&lt;p&gt;Auth0 gives you two supported paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read it from the user's identity record.&lt;/strong&gt; The upstream token shows up at &lt;code&gt;user.identities[0].access_token&lt;/code&gt; (and, for a short allowlist of named providers, a refresh token alongside it). Getting it out requires a Management API call to the Get a User endpoint with the &lt;code&gt;read:user_idp_tokens&lt;/code&gt; scope — your frontend can't read this directly, nor should it be able to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use Auth0's Token Vault&lt;/strong&gt;, where available, which stores upstream access/refresh tokens in a dedicated secure tokenset instead of on the identities array.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Test this before you design around it:&lt;/strong&gt; refresh-token persistence for a custom OIDC connection like Epic is not guaranteed the way it is for a short list of named providers (Google, Azure AD, Bitbucket, SharePoint). Confirm Auth0 is actually retaining Epic's refresh token before you build a refresh strategy that assumes it'll be there — finding out otherwise during a production incident is a bad way to learn this.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If neither mechanism reliably gives you what the application needs — particularly long-lived refresh capability — your remaining options are performing the SMART authorization-code exchange directly in your own backend (bypassing Auth0 for the Epic-specific leg), or building a separate backend service purely responsible for Epic token lifecycle: refresh, revocation, expiry, per-tenant isolation. This is usually the fork in the road between "Auth0 brokers everything" and the hybrid architecture covered later in &lt;em&gt;Should Auth0 sit in the middle?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Whatever you choose, don't put the full Epic access token inside an Auth0 custom claim. That token would then ride along inside browser-visible ID tokens, and from there into logs, session storage, frontend debugging tools, and analytics — none of which should ever see a FHIR access token. Keep it backend-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store launch context separately from the user identity
&lt;/h2&gt;

&lt;p&gt;SMART context belongs to a specific authorization session. It should never quietly become permanent user profile data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User identity:  Epic subject, FHIR user, tenant
SMART session:  patient, encounter, FHIR server, scopes, Epic access token, token expiry, launch context
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A clinician launches the same application for a different patient every hour. If the patient ID lives on the permanent user identity, the second launch corrupts the first. Attach it to the session instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"session-123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user-456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tenant-a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fhir_server"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://hospital-a.example.com/api/FHIR/R4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"patient"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"patient-789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"encounter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"encounter-101"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;Delete or expire it when the session ends. It shouldn't outlive the reason it existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate the token response
&lt;/h2&gt;

&lt;p&gt;Once Epic completes the authorization-code exchange, actually check what came back.&lt;/p&gt;

&lt;p&gt;For the ID token: signature, issuer, audience, expiry, issued-at time, nonce, and subject. For the Epic access token and SMART context: that the access token and its expiry exist, the scope matches what you requested, the FHIR server matches the trusted issuer, and required patient/encounter context and &lt;code&gt;fhirUser&lt;/code&gt; are present when your flow needs them.&lt;/p&gt;

&lt;p&gt;Don't assume every successful token response includes patient context — what comes back depends on launch type, requested scopes, Epic's app configuration, user authorization, and whatever EHR context was actually available. If the application requires a patient and none arrives, stop and show an appropriate message. Don't fall back to making unscoped FHIR requests just because the login technically succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add structured logging around each redirect boundary
&lt;/h2&gt;

&lt;p&gt;Brokered OAuth flows are hard to debug precisely because the browser hops across multiple domains and you can't just attach a debugger to the whole journey. Add structured logs at each major step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"smart_launch_received"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tenant-a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://hospital-a.example.com/api/FHIR/R4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"has_launch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth0_authorization_started"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tenant-a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"organization_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"connection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"epic-hospital-a"&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="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth0_callback_received"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tenant-a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"state_valid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"smart_session_created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tenant-a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"has_patient"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"has_encounter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scope_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&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;Never log the authorization code, access token, refresh token, client secret, full ID token, or opaque launch value.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Check your middleware, not just your handlers:&lt;/strong&gt; application-level discipline about redaction is easy to undo one layer down. A blanket "log every request" middleware — the kind teams add for general observability — will often capture the &lt;code&gt;Authorization&lt;/code&gt; header, the full query string (which contains &lt;code&gt;code=&lt;/code&gt; and &lt;code&gt;launch=&lt;/code&gt; on a redirect), or a raw JSON request body at a token-exchange endpoint. Audit the entire path a request takes through your stack, not just the function that handles it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Test each integration layer independently
&lt;/h2&gt;

&lt;p&gt;Don't debug the complete brokered flow as one unit — isolate it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Epic discovery&lt;/strong&gt; — the discovery, token, and JWKS endpoints are reachable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct Epic authorization request&lt;/strong&gt; — the Epic client registration accepts the intended &lt;code&gt;response_type&lt;/code&gt;, &lt;code&gt;redirect_uri&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;, &lt;code&gt;aud&lt;/code&gt;, and &lt;code&gt;launch&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth0 enterprise connection&lt;/strong&gt; — Auth0 can authenticate against Epic with no organization restrictions in play yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth0 Organizations&lt;/strong&gt; — add the organization and membership rules only after the base connection works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SMART launch propagation&lt;/strong&gt; — &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;aud&lt;/code&gt;, and &lt;code&gt;launch&lt;/code&gt; actually reach Epic intact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FHIR API access&lt;/strong&gt; — the Epic-issued access token works against the intended FHIR endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Six stages, six separate places a bug can hide. Collapsing them into one end-to-end test is how you end up guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production checklist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Epic registration&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Correct Epic environment&lt;/li&gt;
&lt;li&gt;[ ] Correct client ID and client secret&lt;/li&gt;
&lt;li&gt;[ ] Auth0 callback registered&lt;/li&gt;
&lt;li&gt;[ ] Authorization Code Flow enabled&lt;/li&gt;
&lt;li&gt;[ ] Required FHIR APIs and scopes approved&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Auth0 connection&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] OIDC enterprise connection created&lt;/li&gt;
&lt;li&gt;[ ] Back-channel flow configured&lt;/li&gt;
&lt;li&gt;[ ] Issuer, token, and JWKS endpoints configured (manually, if Epic doesn't expose standard OIDC discovery)&lt;/li&gt;
&lt;li&gt;[ ] Connection enabled for application&lt;/li&gt;
&lt;li&gt;[ ] Connection enabled for organization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;SMART flow&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Incoming &lt;code&gt;iss&lt;/code&gt; validated against a trusted, per-tenant allowlist&lt;/li&gt;
&lt;li&gt;[ ] Launch value treated as opaque&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;aud&lt;/code&gt; matches the FHIR server&lt;/li&gt;
&lt;li&gt;[ ] Correct scopes requested&lt;/li&gt;
&lt;li&gt;[ ] EHR and standalone launch kept as separate entry points&lt;/li&gt;
&lt;li&gt;[ ] State stored securely, single-use, and expiring&lt;/li&gt;
&lt;li&gt;[ ] Nonce validated&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Identity&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Stable external identity key selected (not email alone)&lt;/li&gt;
&lt;li&gt;[ ] Account-linking rules defined&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;fhirUser&lt;/code&gt; handled as metadata, not a primary key&lt;/li&gt;
&lt;li&gt;[ ] Organization membership configured&lt;/li&gt;
&lt;li&gt;[ ] Tenant isolation enforced&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tokens&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Auth0 and Epic tokens stored separately&lt;/li&gt;
&lt;li&gt;[ ] Epic token remains backend-only&lt;/li&gt;
&lt;li&gt;[ ] Token audiences validated on every API&lt;/li&gt;
&lt;li&gt;[ ] Refresh strategy defined and tested against the real connection, not assumed&lt;/li&gt;
&lt;li&gt;[ ] Revocation behavior and expiry handling defined&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Session&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Patient and encounter context stored per session, not on the user record&lt;/li&gt;
&lt;li&gt;[ ] Launch context expires&lt;/li&gt;
&lt;li&gt;[ ] Logout behavior defined&lt;/li&gt;
&lt;li&gt;[ ] Audit logs added, with sensitive values excluded — including at the middleware/framework logging layer, not just application code&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Should Auth0 sit in the middle?
&lt;/h2&gt;

&lt;p&gt;Auth0 earns its place when your platform already runs on it, Epic is one of several enterprise identity providers, you need centralized organization and session management, Epic is mainly there for authentication, the SMART parameters forward reliably, and the Epic access-token lifecycle has a clear owner.&lt;/p&gt;

&lt;p&gt;A direct SMART integration starts looking better when the application serves many Epic customers with different FHIR issuers, launch parameters are highly dynamic, the app leans heavily on patient and encounter context, you need direct control over PKCE, refresh-token management is central to the product, or getting SMART-specific parameters through Auth0 keeps fighting you.&lt;/p&gt;

&lt;p&gt;Auth0 can also stay part of the broader platform without brokering the Epic SMART flow itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Epic SMART authorization
  → Application validates Epic identity
  → Application links internal user
  → Internal application session
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The right architecture comes down to one question: is Epic acting as your identity provider, your FHIR authorization server, or both? The answer isn't the same for every product, and it's worth answering deliberately instead of defaulting to whatever pattern your last OIDC integration used.&lt;/p&gt;

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

&lt;p&gt;Using Auth0 as an identity broker for Epic SMART on FHIR is possible, but it's not a standard enterprise OIDC login wearing a different logo. Keep the responsibilities explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Epic manages&lt;/strong&gt; — SMART authorization, FHIR scopes, FHIR launch context, FHIR access tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth0 manages&lt;/strong&gt; — enterprise identity brokering, organizations, Auth0 sessions, Auth0 tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your application manages&lt;/strong&gt; — tenant selection, launch validation, state correlation, SMART session context, FHIR API usage, token isolation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once those boundaries are explicit, the integration becomes something you can actually design, test, and operate — instead of something that works until the first real hospital points a real EHR launch at it. The question was never whether Epic supports OIDC. It does. The question is whether every SMART-specific value your application depends on can move through the identity broker without losing its meaning along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://hl7.org/fhir/smart-app-launch/" rel="noopener noreferrer"&gt;SMART App Launch specification (HL7)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://auth0.com/docs/authenticate/identity-providers/enterprise-identity-providers/oidc" rel="noopener noreferrer"&gt;Auth0: Connect to an OpenID Connect Identity Provider&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://auth0.com/docs/secure/tokens/access-tokens/identity-provider-access-tokens" rel="noopener noreferrer"&gt;Auth0: Identity Provider Access Tokens&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://auth0.com/docs/manage-users/organizations/configure-organizations/enable-connections" rel="noopener noreferrer"&gt;Auth0: Enable Organization Connections&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>auth0</category>
      <category>fhir</category>
      <category>oauth</category>
      <category>security</category>
    </item>
    <item>
      <title>Part 10 — Lessons learned building a Kubernetes Auth Gateway</title>
      <dc:creator>Akarshan Gandotra</dc:creator>
      <pubDate>Mon, 04 May 2026 18:42:44 +0000</pubDate>
      <link>https://dev.to/akarshan/lessons-learned-building-a-kubernetes-auth-gateway-100m</link>
      <guid>https://dev.to/akarshan/lessons-learned-building-a-kubernetes-auth-gateway-100m</guid>
      <description>&lt;p&gt;We're at the end of the series. Nine chapters of mechanism. One chapter of opinion.&lt;/p&gt;

&lt;p&gt;Building the Auth Gateway took roughly two years from "what if NGINX did the auth?" to "this thing handles every authenticated request in production." A lot of what's in the previous chapters wasn't obvious to us at the start. This is the post-mortem on our own architecture: what worked, what hurt, what we'd build earlier, and what we'd warn the next team about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What worked
&lt;/h2&gt;

&lt;p&gt;A few decisions held up cleanly. We'd make all of them again.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;auth_request&lt;/code&gt; as the primitive
&lt;/h3&gt;

&lt;p&gt;NGINX's &lt;code&gt;auth_request&lt;/code&gt; directive is, with no exaggeration, the single most leveraged design choice in the platform. One directive, well-understood, supported across NGINX versions. We don't need a service mesh. We don't need a custom Envoy filter. We don't need a Lua module compiled into NGINX.&lt;/p&gt;

&lt;p&gt;If you can do your auth in HTTP-status terms (200/401/403), &lt;code&gt;auth_request&lt;/code&gt; is the right tool. If you can't, you probably want a sidecar or mesh-level enforcement and this whole architecture doesn't apply.&lt;/p&gt;

&lt;h3&gt;
  
  
  Endpoint metadata as data
&lt;/h3&gt;

&lt;p&gt;Storing endpoint type and required permissions in Postgres, refreshed via Pub/Sub, was the right call. It means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We can change auth without redeploying the gateway.&lt;/li&gt;
&lt;li&gt;We can audit auth ("what protects this URL?") with a SQL query.&lt;/li&gt;
&lt;li&gt;Admin tooling and the gateway share a single contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost — a small DB lookup at boot, an in-memory trie, a refresh mechanism — was tiny compared to the operational flexibility we got back.&lt;/p&gt;

&lt;h3&gt;
  
  
  One structured log line per decision
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;AUTH_DECISION&lt;/code&gt; is the contract between the Auth Service and oncall. Every field, every time, every request. A year of operations later, this is the artifact we reference most often. Every alert we've built points at it. Every incident postmortem references it.&lt;/p&gt;

&lt;p&gt;Resist the temptation to add &lt;code&gt;INFO&lt;/code&gt;/&lt;code&gt;DEBUG&lt;/code&gt; lines around it. Resist the temptation to omit fields when they're "not relevant." One line. Same shape. Forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fail-closed by default at the edge
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;error_page 502 503 504 = @auth_unavailable;&lt;/code&gt; was a one-line change that defines our security posture. When the Auth Service is unhealthy, NGINX returns 503 to the client &lt;em&gt;instead of&lt;/em&gt; letting the request through. We've had a few incidents where this caused brief platform-wide outages. We have never regretted the choice.&lt;/p&gt;

&lt;p&gt;The principle: the cost of a 5-minute outage on rare occasions is much, much less than the cost of one cross-tenant data leak ever.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caches in the auth process, not in NGINX
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;auth_request&lt;/code&gt; is intentionally not cacheable, and we leaned into that. Every cache lives inside the Auth Service: JWT verify, RSA keys, route lookup, policy bitmap, revocation map, SA versions. Each is invalidated through its own channel. The gateway's hot path makes zero Redis calls in steady state.&lt;/p&gt;

&lt;p&gt;This kept the architecture honest. The auth pod is the unit of correctness. Scale it, monitor it, debug it as one thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pub/Sub-driven trie reload
&lt;/h3&gt;

&lt;p&gt;Push-based invalidation for the endpoint trie was the right shape. Periodic-only would have given us a ~30 minute window where new admin routes were unprotected. Pub/Sub-only would have been brittle (events get lost). Both, with periodic as the safety net, gives us seconds of staleness in the common case and bounded staleness even when the message is lost.&lt;/p&gt;

&lt;p&gt;Most caches we'd default to TTL. The trie was worth the special case.&lt;/p&gt;

&lt;h3&gt;
  
  
  The bitmap fast path
&lt;/h3&gt;

&lt;p&gt;Encoding permissions as bit indexes paid off. Smaller tokens, faster checks, cleaner metrics. The legacy path we kept around for safety has earned its keep — version skew is real, and fall-through is graceful.&lt;/p&gt;

&lt;p&gt;Shadow mode for two months before flipping the switch was the right rollout pattern. Catching three real bugs in shadow with zero impact on production is the gold standard for a sensitive change like authorization logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What hurt
&lt;/h2&gt;

&lt;p&gt;Now the harder list. Things that cost us time, sleep, or trust.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tenant resolution living in two places
&lt;/h3&gt;

&lt;p&gt;NGINX resolves the tenant. The Auth Service &lt;em&gt;also&lt;/em&gt; checks tenant binding (token tenant matches request tenant). The two places do &lt;em&gt;different&lt;/em&gt; checks for a reason — but the reason isn't obvious, and we've watched several engineers add tenant logic to a third place because they didn't realize it was already covered.&lt;/p&gt;

&lt;p&gt;What we'd do differently: write a single tenant-resolution doc that explicitly enumerates &lt;em&gt;which layer owns what&lt;/em&gt; and &lt;em&gt;what each layer assumes about the others&lt;/em&gt;. A "tenancy contract" page. We have it now (chapter 5 is a recovered version of it); we should have had it on day one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The "first segment is the slug" rule
&lt;/h3&gt;

&lt;p&gt;For a long time, the Auth Service split the URI on &lt;code&gt;/&lt;/code&gt; and treated the first segment as the service slug. This worked until services started nesting each other or grouping under shared prefixes. We had to retrofit &lt;code&gt;X-Service-Slug&lt;/code&gt; and &lt;code&gt;X-Request-Path&lt;/code&gt; headers — backward-compatibly, with fallback to the old rule. The retrofit is fine; it took longer than it should have because the old rule was buried in three places.&lt;/p&gt;

&lt;p&gt;What we'd do differently: explicit slug headers from day one. Don't infer slugs from the URI structure. The path inside a service is the service's business; the slug is a separate concern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migration to the bitmap took longer than expected
&lt;/h3&gt;

&lt;p&gt;The bitmap fast path was a six-week project that took five months. The math was straightforward. What ate the time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Coordinating bit-index assignments with the token issuer team (different repo, different rollout cadence).&lt;/li&gt;
&lt;li&gt;Fixture data in our test suites was hardcoded with old permission strings; updating it for the bitmap registry was a long tail of small PRs.&lt;/li&gt;
&lt;li&gt;The shadow comparison logic exposed three subtle bugs (Chapter 6) that each required investigation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What we'd do differently: assume cross-team auth changes are 3x what you estimate. Build the shadow harness &lt;em&gt;first&lt;/em&gt;, then the new path. The shadow harness paid for itself five times over.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cache invalidation was an afterthought
&lt;/h3&gt;

&lt;p&gt;The first version of the JWT cache was a &lt;code&gt;map&lt;/code&gt; with &lt;code&gt;time.AfterFunc&lt;/code&gt; evictors. We covered it in Chapter 8. It seemed fine. It fell over in production within a week.&lt;/p&gt;

&lt;p&gt;The lesson generalizes: &lt;strong&gt;a cache without a written-down invalidation channel is a memory leak.&lt;/strong&gt; Every cache should have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A bounded size (entries or bytes).&lt;/li&gt;
&lt;li&gt;A clear invalidation event ("token expired", "trie reloaded", "revocation event consumed").&lt;/li&gt;
&lt;li&gt;A staleness window we can articulate ("up to 30 seconds late").&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can't write those three down, don't add the cache.&lt;/p&gt;

&lt;h3&gt;
  
  
  The default tenant we shipped on day one
&lt;/h3&gt;

&lt;p&gt;For the first quarter we had a default tenant. "If no &lt;code&gt;X-Tenant-ID&lt;/code&gt; and no host match, fall through to &lt;code&gt;default-tenant&lt;/code&gt;." It was added because it made local dev easier.&lt;/p&gt;

&lt;p&gt;It cost us in two ways. First, removing it took longer than building it — every misconfigured client started 400'ing once we removed the fallback. Second, while it was live, it produced exactly one near-miss data leak (a service-account request without a tenant header writing into the wrong tenant). We caught it before it left staging.&lt;/p&gt;

&lt;p&gt;Shipping that default tenant was the worst single decision in the whole project. We'd remove it from every future system before it ever boots.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-pod alert spam, twice
&lt;/h3&gt;

&lt;p&gt;Twice we shipped alert code that fired per-request rather than per-state. The first time was during a Redis outage in our second month (lit up Slack with ~10k messages in 90 seconds). The second was during an RSA misconfig rollout (a few hundred messages per minute per pod, fleet-wide).&lt;/p&gt;

&lt;p&gt;Both were the same bug: alerting from a request handler instead of from a state-transition observer. Both were "fixed" with &lt;code&gt;atomic.Bool&lt;/code&gt; swaps. Now we apply that pattern aggressively.&lt;/p&gt;

&lt;p&gt;What we'd do differently: write the alert dedup helper &lt;em&gt;first&lt;/em&gt;, before the first alert. Have it baked into the codebase before there's anything to alert on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd build earlier
&lt;/h2&gt;

&lt;p&gt;In the order we'd add them:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The structured &lt;code&gt;AUTH_DECISION&lt;/code&gt; log
&lt;/h3&gt;

&lt;p&gt;On day one. Even before fancy auth logic. The log structure outlives every other choice — every dashboard, every alert, every postmortem reads from it. Build the contract first.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Slack alert dedup helper
&lt;/h3&gt;

&lt;p&gt;Before the first alert. Five lines of code to wrap an &lt;code&gt;atomic.Bool&lt;/code&gt; around a Slack send. Ship it before you have anything to alert on.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Fail-closed posture
&lt;/h3&gt;

&lt;p&gt;Before the gateway sees a single request in production. Don't even try permissive defaults. The "we'll tighten it later" path becomes "we shipped a permissive-by-default thing for 18 months." Just ship it tight.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Endpoint metadata in DB
&lt;/h3&gt;

&lt;p&gt;Skip the YAML-of-routes phase. Skip the in-code decorator phase. Go straight to the database table with refresh mechanism. The transitional architectures cost more to migrate off than they cost to skip.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Gap probe on revocation streams
&lt;/h3&gt;

&lt;p&gt;The probe (Chapter 7) catches data loss between the stream and consumers. It costs almost nothing to run. Without it you don't &lt;em&gt;know&lt;/em&gt; if you're losing events; you just hope.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Shadow harness for sensitive changes
&lt;/h3&gt;

&lt;p&gt;Comparing old-vs-new in production with the new path muted is a powerful pattern. Build the harness as a reusable thing. We re-implemented variants of it for three different rollouts before realizing it should be a library.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Tenancy contract document
&lt;/h3&gt;

&lt;p&gt;One page. Owns: which layer resolves the tenant, which layer validates token-tenant binding, which layer scopes queries, what the failure modes are. Required reading before anyone touches request handling. Should have existed before the gateway shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maturity progression
&lt;/h2&gt;

&lt;p&gt;Looking back, the gateway evolved through identifiable stages. They're worth naming because if you're starting fresh, knowing the destination shape lets you skip steps.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvg5kduswix3k07ujlris.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvg5kduswix3k07ujlris.png" alt=" " width="800" height="278"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1 — per-service auth libs.&lt;/strong&gt; Where most teams are. Each service has its own JWT decode, its own permission check. Inconsistent, drift-prone, slow to fix CVEs. Don't stay here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v2 — auth_request + minimal &lt;code&gt;/auth&lt;/code&gt;.&lt;/strong&gt; A simple gateway that decodes a token and returns 200/401. Static list of "open" routes. Enough to centralize the decision; not enough to scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v3 — trie + classification + Pub/Sub.&lt;/strong&gt; Endpoint metadata in a DB. Trie in memory. Refresh kicks. Now adding a route doesn't require a redeploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v4 — revocation + caching.&lt;/strong&gt; Logout works. Admin disable works. Each cache layer in place. Hot path is sub-millisecond.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v5 — bitmap + structured logs + degraded mode.&lt;/strong&gt; The mature gateway. Fast, observable, alertable, recoverable.&lt;/p&gt;

&lt;p&gt;Most of the value lives between v2 and v3. If you're at v1, that's the migration to plan for. v3 to v5 is iteration; v1 to v3 is the &lt;em&gt;project&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five pieces of advice for teams building this
&lt;/h2&gt;

&lt;p&gt;If you're starting from scratch with a similar problem, here's what I'd hand off in five bullets:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Start with &lt;code&gt;auth_request&lt;/code&gt;. Don't shop architectures.
&lt;/h3&gt;

&lt;p&gt;Service mesh, custom Envoy filter, Lua plugin, sidecar — they all promise more flexibility. They all cost more in operations. &lt;code&gt;auth_request&lt;/code&gt; is enough for the 90% case, and the 10% is rarely worth the complexity.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Make the gateway HA before anything else.
&lt;/h3&gt;

&lt;p&gt;Two replicas minimum, HPA, graceful shutdown, retries to upstream auth pod, circuit-breaker semantics in NGINX, fail-closed posture. If any one of these is missing the gateway &lt;em&gt;will&lt;/em&gt; take down your platform during a normal degraded event. This isn't optional.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The log is the API.
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;AUTH_DECISION&lt;/code&gt; log is a public contract with everyone who ever debugs your gateway. Treat it like a schema. Don't change field names without a migration. Don't add free-form strings to enum fields. Have one version-controlled doc that defines every field and every value of every enum.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Cache invalidation has to be explicit.
&lt;/h3&gt;

&lt;p&gt;Every cache: bounded size, explicit invalidation channel, articulable staleness window. If a cache doesn't have all three, it's a bug-in-waiting. We learned this twice.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Build observability before you build features.
&lt;/h3&gt;

&lt;p&gt;Dashboards, alerts, trace context, the structured log — all of these come &lt;em&gt;before&lt;/em&gt; you ship the cool feature you're excited about. A clever new permission model that you can't observe is worse than a boring permission model you can.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd build next
&lt;/h2&gt;

&lt;p&gt;A few things on our list that didn't fit this series:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NGINX otel module compiled in.&lt;/strong&gt; Right now NGINX traces are limited; the Auth Service has full spans, but the NGINX hop is a black box from the trace's point of view. Worth fixing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-tenant rate limiting.&lt;/strong&gt; Currently we rely on upstream services. The gateway is the natural place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WAF integration.&lt;/strong&gt; We have an external WAF. Closer integration so WAF events show up in &lt;code&gt;AUTH_DECISION&lt;/code&gt; would help triage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token introspection cache.&lt;/strong&gt; Some integrations issue opaque tokens that we have to introspect with the issuer. Caching that lookup is its own caching problem; we haven't tackled it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A formal "tenancy contract" page.&lt;/strong&gt; Yes, the same one I told you to build on day one. We're catching up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is a future series, probably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final architecture
&lt;/h2&gt;

&lt;p&gt;For posterity, the picture of where we ended up:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwcoi62l7pj0x3ypscgj8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwcoi62l7pj0x3ypscgj8.png" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything in this picture has been earned by an outage, a postmortem, or a near-miss. None of it is decoration. If you're building something similar and one of the boxes seems extra to you, it's because you haven't had the incident that justifies it yet.&lt;/p&gt;

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

&lt;p&gt;Centralizing auth at the edge is one of those decisions that looks obviously correct in hindsight and is genuinely hard to convince a team to invest in beforehand. The wins are diffuse — slightly less drift, slightly fewer CVEs, slightly faster security responses. The pain is concentrated and visible — one new service to operate, one extra hop, one more place that has to be HA.&lt;/p&gt;

&lt;p&gt;But every six months we look back and the gateway has paid for itself again. A library upgrade we did once instead of thirty times. A revocation feature that shipped in a week instead of being negotiated across teams. A multi-tenant isolation guarantee we can actually defend in audits.&lt;/p&gt;

&lt;p&gt;If you take one thing from this series, take this: &lt;strong&gt;&lt;code&gt;auth&lt;/code&gt; is not a problem you solve once and ignore. It's a problem you solve &lt;em&gt;somewhere&lt;/em&gt;, well, and operate with care.&lt;/strong&gt; Pick that &lt;em&gt;somewhere&lt;/em&gt; to be the edge, build it small and observable, and the rest of your platform gets to focus on actual product work.&lt;/p&gt;

&lt;p&gt;Thanks for reading. If you build one of these — or are stuck somewhere mid-build — drop a comment. The hardest part of operating an Auth Gateway is realizing that other people have built the same thing and hit the same rocks. There's no reason for each team to find them independently.&lt;/p&gt;

</description>
      <category>lessonslearned</category>
      <category>architecture</category>
      <category>platformengineering</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Part 9 — Operating the gateway: logs, traces, health, and degraded mode</title>
      <dc:creator>Akarshan Gandotra</dc:creator>
      <pubDate>Mon, 04 May 2026 18:42:27 +0000</pubDate>
      <link>https://dev.to/akarshan/operating-the-gateway-logs-traces-health-and-degraded-mode-2209</link>
      <guid>https://dev.to/akarshan/operating-the-gateway-logs-traces-health-and-degraded-mode-2209</guid>
      <description>&lt;p&gt;The first eight chapters of this series have been about &lt;em&gt;building&lt;/em&gt; an Auth Gateway. This one is about &lt;em&gt;living&lt;/em&gt; with one.&lt;/p&gt;

&lt;p&gt;A gateway in front of every authenticated request is a force multiplier — for both your platform and any oncall page. If something is broken, it's broken everywhere at once. So observability isn't a Chapter 9 thing. It's a Chapter 0 thing. We just describe it last because there's enough mechanism to talk about that you need the rest of the series first.&lt;/p&gt;

&lt;p&gt;This chapter covers the four things you need to be able to do at 3 AM:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read a single log line and understand what happened.&lt;/li&gt;
&lt;li&gt;Trace a slow request from edge to upstream.&lt;/li&gt;
&lt;li&gt;Tell whether a pod is alive, ready, or in deep trouble.&lt;/li&gt;
&lt;li&gt;Get an alert &lt;em&gt;once&lt;/em&gt; — not once per pod per second — when something degrades.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The log line
&lt;/h2&gt;

&lt;p&gt;There are exactly two structured log lines per protected request: one from NGINX, one from the Auth Service. They share &lt;code&gt;request_id&lt;/code&gt;, so you can join them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The NGINX line
&lt;/h3&gt;

&lt;p&gt;Every request is logged as JSON to stdout via NGINX's &lt;code&gt;log_format main&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;log_format&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt; &lt;span class="s"&gt;escape=json&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;"logType":"NGINX_LOGS",&lt;/span&gt;
  &lt;span class="s"&gt;"request_id":"&lt;/span&gt;&lt;span class="nv"&gt;$request_id&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"time_local":"&lt;/span&gt;&lt;span class="nv"&gt;$time_iso8601&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"remote_addr":"&lt;/span&gt;&lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"request_method":"&lt;/span&gt;&lt;span class="nv"&gt;$request_method&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"request_uri":"&lt;/span&gt;&lt;span class="nv"&gt;$request_uri&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"request_path":"&lt;/span&gt;&lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"slug":"&lt;/span&gt;&lt;span class="nv"&gt;$location_path&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"product":"&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"microservice":"&lt;/span&gt;&lt;span class="nv"&gt;$microservice&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"status":"&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"status_class":"&lt;/span&gt;&lt;span class="nv"&gt;$status_class&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"request_time_ms":"&lt;/span&gt;&lt;span class="nv"&gt;$request_time_millis&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"service_request_time_ms":"&lt;/span&gt;&lt;span class="nv"&gt;$upstream_response_time_millis&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"service_connect_time":"&lt;/span&gt;&lt;span class="nv"&gt;$upstream_connect_time&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"auth_request_time_ms":"&lt;/span&gt;&lt;span class="nv"&gt;$auth_time_millis&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"auth_connect_time":"&lt;/span&gt;&lt;span class="nv"&gt;$auth_connect_time&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"body_bytes_sent":"&lt;/span&gt;&lt;span class="nv"&gt;$body_bytes_sent&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"http_referer":"&lt;/span&gt;&lt;span class="nv"&gt;$http_referer&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"http_user_agent":"&lt;/span&gt;&lt;span class="nv"&gt;$http_user_agent&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"http_x_forwarded_for":"&lt;/span&gt;&lt;span class="nv"&gt;$http_x_forwarded_for&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"http_host":"&lt;/span&gt;&lt;span class="nv"&gt;$http_host&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"tenant_id":"&lt;/span&gt;&lt;span class="nv"&gt;$tenant_id&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"tenant_namespace":"&lt;/span&gt;&lt;span class="nv"&gt;$tenant_namespace&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"identity_id":"&lt;/span&gt;&lt;span class="nv"&gt;$identity_id&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"identity_type":"&lt;/span&gt;&lt;span class="nv"&gt;$identity_type&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"auth_error_message":"&lt;/span&gt;&lt;span class="nv"&gt;$auth_error_message&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;
  &lt;span class="s"&gt;"auth_error_code":"&lt;/span&gt;&lt;span class="nv"&gt;$auth_error_code&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few fields that matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;auth_request_time_ms&lt;/code&gt;&lt;/strong&gt; — how long the auth subrequest took. We graph this. We page on the p99 going above 50 ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;service_request_time_ms&lt;/code&gt;&lt;/strong&gt; — how long the upstream took, &lt;em&gt;excluding&lt;/em&gt; the auth subrequest. Sequential, not overlapping (Chapter 2).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;status_class&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;1xx&lt;/code&gt;/&lt;code&gt;2xx&lt;/code&gt;/&lt;code&gt;3xx&lt;/code&gt;/&lt;code&gt;4xx&lt;/code&gt;/&lt;code&gt;5xx&lt;/code&gt;. Faster than parsing &lt;code&gt;status&lt;/code&gt; for dashboard breakdowns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tenant_id&lt;/code&gt;&lt;/strong&gt; — the resolved tenant. Always grep by tenant first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;auth_error_code&lt;/code&gt;&lt;/strong&gt; + &lt;strong&gt;&lt;code&gt;auth_error_message&lt;/code&gt;&lt;/strong&gt; — populated on deny. Empty on allow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Two extra log formats for the fail paths
&lt;/h3&gt;

&lt;p&gt;When NGINX hits &lt;code&gt;@auth_unavailable&lt;/code&gt; or &lt;code&gt;@upstream_unavailable&lt;/code&gt;, we log to a different format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;log_format&lt;/span&gt; &lt;span class="s"&gt;auth_unavailable&lt;/span&gt;     &lt;span class="s"&gt;'...auth-specific&lt;/span&gt; &lt;span class="s"&gt;fields...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;log_format&lt;/span&gt; &lt;span class="s"&gt;upstream_unavailable&lt;/span&gt; &lt;span class="s"&gt;'...upstream-specific&lt;/span&gt; &lt;span class="s"&gt;fields...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="s"&gt;@auth_unavailable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;access_log&lt;/span&gt; &lt;span class="n"&gt;/dev/stdout&lt;/span&gt; &lt;span class="s"&gt;auth_unavailable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt; &lt;span class="s"&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 reason: when something is on fire, you want it isolated in its own log stream. Dashboards built off &lt;code&gt;main&lt;/code&gt; get drowned by 200s; a dashboard against &lt;code&gt;auth_unavailable&lt;/code&gt; shows you exactly the broken bucket without filtering.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Auth Service line
&lt;/h3&gt;

&lt;p&gt;The Auth Service emits exactly one &lt;code&gt;AUTH_DECISION&lt;/code&gt; log per request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"info"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-05-01T12:34:56.789Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"logger"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"AUTH_DECISION"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"550e8400-e29b-41d4-a716-446655440000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"4bf92f3577b34da6a3ce929d0e0e4736"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"span_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"00f067aa0ba902b7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"uri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"/api/v1/users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"slug"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"user-service"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"mt_prod"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"endpoint_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ACCESS_CONTROLLED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"identity_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"USER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"identity_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"user@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"auth_method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"bearer_token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"decision_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ACCESS_LEVEL_MATCH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authn_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;1.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authz_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jwt_cache_hit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bitmap_authz_used"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"granted_access_levels"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"product:admin"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_revoked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"slow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&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;This is the single most important artifact in the whole gateway. It is, in dry terms, our auth audit log. In practical terms it's the thing oncall greps when anything goes weird.&lt;/p&gt;

&lt;p&gt;Design rules we apply to it religiously:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exactly one line per request.&lt;/strong&gt; No "starting auth", "authenticated", "authorizing", "decided" — those make the storyline split across N entries that you have to stitch back together. One line, one decision, every field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every field, every time.&lt;/strong&gt; If a field doesn't apply (e.g., &lt;code&gt;bitmap_authz_used&lt;/code&gt; for an OPEN endpoint), it's &lt;code&gt;false&lt;/code&gt; or empty, not omitted. Optional fields make ad-hoc queries painful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;decision_reason&lt;/code&gt; is enum-only.&lt;/strong&gt; Free-form strings here would be the death of dashboards. New reasons require code review (Chapter 3).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trace IDs are present.&lt;/strong&gt; &lt;code&gt;trace_id&lt;/code&gt; and &lt;code&gt;span_id&lt;/code&gt; are pulled from the OpenTelemetry context, so the log line stitches to traces in our backend without join logic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Joining NGINX and Auth Service
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;request_id&lt;/code&gt; is generated by NGINX (&lt;code&gt;$request_id&lt;/code&gt;) and forwarded to the Auth Service via the subrequest header &lt;code&gt;X-Request-ID&lt;/code&gt;. Both log lines carry it. A typical investigation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. user reports 401 at 12:34:56 UTC
2. grep tenant_id="mt_prod" identity_id="..." in NGINX logs around the time
3. capture request_id
4. grep that request_id in Auth Service logs
5. read decision_reason — full story
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole graph of "client → ingress → NGINX → Auth Service → upstream" stitches back together by &lt;code&gt;request_id&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracing
&lt;/h2&gt;

&lt;p&gt;OpenTelemetry instrumentation runs across both NGINX (via the otel module if compiled in; we're tracking that as a future improvement) and the Auth Service. The Auth Service span looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SPAN: pth-auth-service POST /auth
├── ATTRIBUTES
│   ├── http.route = "/auth"
│   ├── auth.tenant_id = "mt_prod"
│   ├── auth.endpoint_type = "ACCESS_CONTROLLED"
│   ├── auth.outcome = "allow"
│   └── auth.decision_reason = "ACCESS_LEVEL_MATCH"
├── EVENTS
│   ├── jwt.cache.hit
│   ├── route.cache.hit
│   └── bitmap.match
└── DURATION 2.5ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trace context propagates to upstream services via standard W3C trace headers. So a single trace shows: client → ingress → NGINX (eventually, via otel module) → Auth Service span → upstream service span(s) → DB calls inside the upstream. The whole story.&lt;/p&gt;

&lt;p&gt;We &lt;em&gt;don't&lt;/em&gt; turn on full sampling. 1% sampling at edges, 100% sampling for spans tagged &lt;code&gt;auth.outcome="deny"&lt;/code&gt;. The deny path is where the interesting investigations happen; sampling it fully gives us forensic detail without exploding storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health probes
&lt;/h2&gt;

&lt;p&gt;Three K8s probe endpoints, each with a different purpose.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;/livez&lt;/code&gt; — process is alive
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Liveness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusOK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ok"&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;Returns 200, always. The contract: as long as this handler runs, the process isn't deadlocked. K8s only kills the pod if the request times out (handler doesn't run at all).&lt;/p&gt;

&lt;p&gt;What &lt;code&gt;/livez&lt;/code&gt; does &lt;em&gt;not&lt;/em&gt; check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It does not check the trie.&lt;/li&gt;
&lt;li&gt;It does not check Redis.&lt;/li&gt;
&lt;li&gt;It does not check Postgres.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is intentional. A pod whose Redis connection died can still serve cache-hot requests correctly. Killing it on a Redis outage is exactly the wrong thing to do — you turn a cache-hit-100%-but-Redis-down state into a cluster-wide rolling restart.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;/readyz&lt;/code&gt; — pod can take traffic
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Readiness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;trieLoaded&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"trie not loaded"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;revocationExpected&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;revocationServiceReady&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"revocation cache not ready"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Trie loaded.&lt;/strong&gt; Without it, we can't classify any endpoint. The pod is useless until the trie is in memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation cache ready&lt;/strong&gt; (if revocation is enabled). Without it, fail-closed designs would deny everything; fail-open designs would miss every revocation. Either way, not ready.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notably &lt;em&gt;not&lt;/em&gt; gated on Redis health: a pod that lost Redis after readiness flips green stays ready. Refreshes fail loudly via Slack, but live traffic isn't disrupted.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;/healthz&lt;/code&gt; — deep health
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Health&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;pgErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pingPostgres&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;rdErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pingRedis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&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;pgErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;rdErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"degraded"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"postgres"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;errString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pgErr&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s"&gt;"redis"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;errString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rdErr&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="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one &lt;em&gt;does&lt;/em&gt; depend on Redis and Postgres. It's not used by Kubernetes — it's used by external monitoring (Pingdom, status pages). The distinction matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;K8s probes determine traffic routing.&lt;/strong&gt; They should be tolerant — every false-positive failure pulls a pod out of service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring probes determine alerting.&lt;/strong&gt; They should be strict — they tell humans something is wrong, not the load balancer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A common mistake is wiring &lt;code&gt;/healthz&lt;/code&gt; to &lt;code&gt;readinessProbe&lt;/code&gt;. Don't. You will pull pods out of the rotation on a transient Redis blip and convert a degraded state into an outage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stateDiagram-v2
    [*] --&amp;gt; Booting
    Booting --&amp;gt; NotReady: trie empty
    NotReady --&amp;gt; Ready: trie loaded AND&amp;lt;br/&amp;gt;(revocation disabled OR revocation cache warm)
    Ready --&amp;gt; Degraded: Redis stream XREAD failure
    Degraded --&amp;gt; Ready: reconnect
    Ready --&amp;gt; Live: /livez always 200
    Ready --&amp;gt; DeepCheck: /healthz pings PG + Redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Degraded mode
&lt;/h2&gt;

&lt;p&gt;The gateway is built to tolerate three specific kinds of trouble:&lt;/p&gt;

&lt;h3&gt;
  
  
  Redis is slow or down
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Revocation stream consumer fails. &lt;code&gt;streamDegraded&lt;/code&gt; atomic flips true. Slack alert fires &lt;em&gt;once&lt;/em&gt;. Hot path keeps working — local cache is in memory.&lt;/li&gt;
&lt;li&gt;Pub/Sub subscriber reconnects in the background. On reconnect, the subscriber re-subscribes. The periodic cleanup goroutine resyncs the ZSET when it next runs.&lt;/li&gt;
&lt;li&gt;SA version sync fails. Local SA version map is unchanged. Tokens continue to validate against the last known versions until the next successful sync. Slack alert fires.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Postgres is slow or down
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Trie reload fails. Existing trie remains in memory. Slack alert. Hot path is unaffected.&lt;/li&gt;
&lt;li&gt;New pods cannot start (initial trie load blocks readiness). Existing pods serve.&lt;/li&gt;
&lt;li&gt;This is a &lt;em&gt;partial&lt;/em&gt; outage: scaling up is broken, current capacity still works.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Slack is slow or down
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Alerts are fire-and-forget goroutines. We don't block the hot path on Slack.&lt;/li&gt;
&lt;li&gt;If Slack is down, alerts are queued in goroutines for a configured timeout (5s) then dropped. We don't retry forever and OOM the pod.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The alert tree
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    Boot[startup] --&amp;gt; A1{revocation Redis OK?}
    A1 --&amp;gt;|no| Alert1[Slack: TokenRevocationService Redis client not initialized&amp;lt;br/&amp;gt;readyz=503]
    Run[runtime] --&amp;gt; A2{XREAD error?}
    A2 --&amp;gt;|yes| Alert2[Slack: stream degraded once]
    A2 --&amp;gt;|recover| Alert3[Slack: stream recovered]
    Run --&amp;gt; A3{localCache size &amp;gt; MAX?}
    A3 --&amp;gt;|yes| Alert4[Slack: cache overflow once&amp;lt;br/&amp;gt;fall back to ZSCORE]
    Run --&amp;gt; A4{RSA key missing for tenant?}
    A4 --&amp;gt;|yes| Alert5[Slack: per-tenant dedup]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Slack-alerter pattern
&lt;/h2&gt;

&lt;p&gt;Three rules we apply rigorously to alert code, because we learned them the hard way:&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 1: Atomic-bool dedup per state transition
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;degradeFlag&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;atomic&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bool&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;degradeFlag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;MarkDegraded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="kt"&gt;string&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Swap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;slack&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"auth.degraded"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&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="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;degradeFlag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;MarkRecovered&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="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Swap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;slack&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"auth.recovered"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Swap&lt;/code&gt; returns the &lt;em&gt;previous&lt;/em&gt; value. If it was already &lt;code&gt;true&lt;/code&gt;, we don't re-alert. We alert once on the transition, and once on the recovery.&lt;/p&gt;

&lt;p&gt;Without this, a Redis outage produces &lt;em&gt;thousands of Slack messages per pod per minute&lt;/em&gt;. The first time it happened, oncall threw their phone across the room.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 2: Per-(pod × cause) dedup, not per-cause
&lt;/h3&gt;

&lt;p&gt;A 100-pod deployment hitting the same RSA key misconfiguration alerts 100 times. That's correct: each pod is a separate runtime, each could have its own state, each is a separate alert source. We tag every alert with the pod's hostname so you can see in Slack whether it's a single-pod or fleet-wide problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 3: Deployment-tag prefix
&lt;/h3&gt;

&lt;p&gt;Every alert is prefixed with &lt;code&gt;[customer-env]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[acme-prod] auth.degraded TokenRevocationService Redis stream degraded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes a single Slack channel viable for many environments. Without the prefix, you can't tell at a glance whether the alert is from staging or prod.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we &lt;em&gt;don't&lt;/em&gt; alert on
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Individual auth failures. Login throttling, expired tokens, denied requests — those are normal and high-volume. They're in dashboards, not Slack.&lt;/li&gt;
&lt;li&gt;High latency on a single request. Latency alerts go on rolling p99, not on individual outliers.&lt;/li&gt;
&lt;li&gt;Anything below "the gateway behaves correctly but degraded." If the system self-heals quietly, we don't page humans.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  NGINX-specific operations
&lt;/h2&gt;

&lt;p&gt;A few NGINX-isms worth calling out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Graceful shutdown
&lt;/h3&gt;

&lt;p&gt;The chart's deployment does this on &lt;code&gt;preStop&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;lifecycle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;preStop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/bin/sh&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo "[preStop] draining 15 seconds..."&lt;/span&gt;
          &lt;span class="s"&gt;sleep 15&lt;/span&gt;
          &lt;span class="s"&gt;echo "[preStop] nginx -s quit..."&lt;/span&gt;
          &lt;span class="s"&gt;nginx -s quit&lt;/span&gt;
          &lt;span class="s"&gt;while pgrep -x nginx &amp;gt; /dev/null; do sleep 1; done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;15 seconds of drain, then &lt;code&gt;nginx -s quit&lt;/code&gt; (graceful — drain in-flight requests, &lt;em&gt;then&lt;/em&gt; exit), then wait for all worker processes to finish. Combined with &lt;code&gt;terminationGracePeriodSeconds: 60&lt;/code&gt;, we have ~60 seconds total budget for clean shutdown. Without this, rolling deploys produced visible spikes of 502s in client logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Worker tuning
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;worker_processes&lt;/span&gt;  &lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;worker_rlimit_nofile&lt;/span&gt; &lt;span class="mi"&gt;65535&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;worker_shutdown_timeout&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;events&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;worker_connections&lt;/span&gt; &lt;span class="mi"&gt;10240&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="s"&gt;epoll&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;multi_accept&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;accept_mutex&lt;/span&gt; &lt;span class="no"&gt;off&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;&lt;code&gt;worker_processes auto&lt;/code&gt; scales to CPU count. &lt;code&gt;accept_mutex off&lt;/code&gt; is the modern default — let kernel &lt;code&gt;epoll&lt;/code&gt; handle accept distribution. &lt;code&gt;worker_connections 10240&lt;/code&gt; is per worker, so a 4-core pod handles ~40k concurrent connections.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upstream keepalive
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;auth_service&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="s"&gt;auth-service-golang.&amp;lt;ns&amp;gt;.svc.cluster.local:80&lt;/span&gt;
         &lt;span class="s"&gt;max_fails=3&lt;/span&gt; &lt;span class="s"&gt;fail_timeout=30s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;keepalive&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;keepalive_requests&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;keepalive_timeout&lt;/span&gt; &lt;span class="s"&gt;60s&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;&lt;code&gt;keepalive 64&lt;/code&gt; is &lt;em&gt;per worker&lt;/em&gt;. A 4-worker NGINX with 64 keepalive maintains 256 keepalive connections to the Auth Service. Without keepalive, every subrequest opens a fresh TCP connection — fatal at any real RPS. The first time we deployed without it, p99 auth time was 80 ms. With it: 3 ms.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max_fails=3 fail_timeout=30s&lt;/code&gt; marks the upstream "down" after 3 consecutive failures and stops sending traffic for 30 seconds. Combined with the retry config in &lt;code&gt;auth.conf&lt;/code&gt;, this gives smooth failover when one auth pod is sick.&lt;/p&gt;

&lt;h2&gt;
  
  
  A picture of where the time goes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gantt
    title Single request as seen by NGINX
    dateFormat  X
    axisFormat %s ms
    section NGINX
    receive + route match : 0, 1
    auth subrequest       : 1, 5
    proxy upstream        : 6, 18
    log JSON              : 24, 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The auth subrequest is the smallest thing on the timeline. That's not by accident. Chapter 8's caches are the reason it stays small. Chapter 9 is what tells you when it stops staying small.&lt;/p&gt;

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

&lt;p&gt;Chapter 10 is the retrospective: what we'd build differently if we did it from scratch, what tools we wish we'd added on day one, and the maturity progression from "auth library in every service" to "production-grade gateway." It's the chapter you write &lt;em&gt;after&lt;/em&gt; operating the thing for two years, and it's the one I'd want to read if I were starting from scratch.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>nginx</category>
      <category>kubernetes</category>
      <category>sre</category>
    </item>
    <item>
      <title>Part 8 — Making It Fast: Caching, Hot Paths, and Avoiding DB Calls</title>
      <dc:creator>Akarshan Gandotra</dc:creator>
      <pubDate>Mon, 04 May 2026 18:42:14 +0000</pubDate>
      <link>https://dev.to/akarshan/making-it-fast-caching-hot-paths-and-avoiding-db-calls-4bbh</link>
      <guid>https://dev.to/akarshan/making-it-fast-caching-hot-paths-and-avoiding-db-calls-4bbh</guid>
      <description>&lt;p&gt;The Auth Gateway sits in front of every authenticated request in the platform. Its latency isn't just its own latency — it's the floor for every service behind it. If auth takes 50ms, every request to every upstream service starts 50ms in the hole.&lt;/p&gt;

&lt;p&gt;Our internal target is sub-millisecond on cache-hot paths. The way we hit it isn't clever algorithms — it's a stack of small caches, each one handling a different kind of state, each invalidated through a different channel. This post walks through all of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The principle that shapes everything
&lt;/h2&gt;

&lt;p&gt;Before the individual layers: a rule we hold as policy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Redis is allowed to &lt;em&gt;influence&lt;/em&gt; the hot path. Redis is not allowed to &lt;em&gt;block&lt;/em&gt; it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every cache in the system is in-process. Redis feeds them asynchronously — pushing revocation events, triggering trie reloads, syncing SA versions. But a pod whose Redis connection is dead can still answer requests correctly, for the duration of its staleness window.&lt;/p&gt;

&lt;p&gt;That's the difference between "Redis is down, the platform is down" and "Redis is down, the platform is slightly stale." One is a severity-1 incident. The other is a degraded mode we can tolerate for minutes while someone fixes it.&lt;/p&gt;

&lt;p&gt;With that framing, here's how a warm request flows through the cache stack:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkpphw3brffith73ndmjb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkpphw3brffith73ndmjb.png" alt=" " width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Six layers. Five are pure in-process memory. The sixth — revocation — is in-process too, but fed asynchronously from Redis. No layer blocks on a network call.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 1: JWT verify cache
&lt;/h2&gt;

&lt;p&gt;The single biggest win in the stack. RSA signature verification is expensive — a few hundred microseconds per call — and at 50,000 RPS that cost is real.&lt;/p&gt;

&lt;p&gt;We wrap the entire decode-and-verify path in a Ristretto cache. The key is a 64-bit FNV hash of the raw token string; the value is the decoded JWT claims. On a cache hit, we skip RSA verification entirely.&lt;/p&gt;

&lt;p&gt;A few choices worth explaining:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Ristretto over a plain LRU.&lt;/strong&gt; Ristretto uses TinyLFU — it tracks access frequency and uses it to decide what to evict. Under burst traffic, a pure LRU can evict frequently-used tokens just because they weren't the &lt;em&gt;most recent&lt;/em&gt;. TinyLFU keeps the hot tokens and evicts the cold ones. The behavior under load is meaningfully better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why hash the token string.&lt;/strong&gt; Two reasons. Memory: a JWT is 500–2000 bytes; a uint64 is 8. And defense-in-depth: if the cache state ever ends up in a log or heap dump, the tokens themselves aren't exposed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why cap TTL at 30 seconds.&lt;/strong&gt; The cache stores the &lt;em&gt;decoded token&lt;/em&gt;, not the auth decision. Revocation is checked separately on every request. But capping TTL at 30 seconds keeps the staleness window honest — a token that's been revoked won't ride a warm cache entry for an hour.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 2: RSA public key cache
&lt;/h2&gt;

&lt;p&gt;Per-tenant RSA public keys are loaded from environment config at boot. Parsing PEM is not free — a few hundred microseconds — and we don't want to pay it on every cache miss.&lt;/p&gt;

&lt;p&gt;We cache the parsed key per tenant using &lt;code&gt;sync.Once&lt;/code&gt;. The first request for a given tenant parses the key; every request after that gets the cached result, including if the first parse failed.&lt;/p&gt;

&lt;p&gt;Two operational details that matter:&lt;/p&gt;

&lt;p&gt;A misconfiguration fires a Slack alert &lt;em&gt;once per tenant per pod&lt;/em&gt;, not once per request. Without this guard, a single bad key config generates a Slack message for every request that hits that tenant, which during a deploy is thousands of messages in seconds.&lt;/p&gt;

&lt;p&gt;Key rotation requires a pod restart. We considered hot-reloading. We chose deploy-to-rotate — the operational simplicity of a predictable restart beats the complexity of a file watcher and the failure modes it introduces.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 3: route cache
&lt;/h2&gt;

&lt;p&gt;The trie lookup is already fast — O(depth), with depth typically 3–5 segments. But re-walking the same paths 50,000 times a second is wasteful. A TinyLFU cache sits in front of the trie, keyed by slug, HTTP method, and path.&lt;/p&gt;

&lt;p&gt;The platform has around 3,000 distinct route tuples in production. Sized at 10,000 entries, the cache fits the entire steady-state working set with room to spare. Misses are new endpoints, cold starts, and post-reload warm-up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invalidation is bulk.&lt;/strong&gt; On any trie reload — whether triggered by a periodic interval or a Redis Pub/Sub kick — we drop the entire route cache. We considered partial invalidation (only drop entries for changed slugs) and rejected it. Trie reloads are rare. The cache refills in milliseconds. The bookkeeping complexity of partial invalidation isn't worth the seconds of warm-up time it would save.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 4: the trie
&lt;/h2&gt;

&lt;p&gt;The trie is a cache too, just an unusual one. It's an in-memory mirror of the endpoint table from Postgres. No request ever touches Postgres on the hot path.&lt;/p&gt;

&lt;p&gt;Invalidation has two channels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Periodic&lt;/strong&gt;: every hour by default. A safety net.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push&lt;/strong&gt;: via Redis Pub/Sub on &lt;code&gt;auth:trie:refresh&lt;/code&gt;. Admin tooling publishes this after any write to the endpoint table. Pods reload within milliseconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The push channel exists because endpoint changes are operationally significant. A new admin route that's meant to be protected shouldn't have a one-hour window where it's open because the trie hasn't refreshed. The push channel closes that window.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 5: policy bitmap snapshot
&lt;/h2&gt;

&lt;p&gt;The permission bitmap (covered in the previous chapter) is loaded alongside the trie. It's an in-memory structure mapping permission names to bit indexes, with a version number.&lt;/p&gt;

&lt;p&gt;The snapshot is never partially updated. It's swapped atomically — a background process builds a new snapshot when the registry changes, then stores it via an atomic pointer swap. Readers grab the pointer at the start of a request and work with that exact snapshot throughout. No locks, no torn reads.&lt;/p&gt;

&lt;p&gt;This pattern shows up repeatedly in the codebase: when state changes as a whole unit, an atomic pointer is simpler and faster than a read-write mutex around a map. It's worth internalizing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 6: revocation map and SA version map
&lt;/h2&gt;

&lt;p&gt;These were covered in depth in the previous chapter. In the context of the cache stack:&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;revocation map&lt;/strong&gt; is bounded at 50,000 JTIs, fed by a Redis Stream, and fails open — if a JTI isn't in the map, we treat it as not revoked. The staleness window is low single-digit seconds in steady state.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;SA version map&lt;/strong&gt; has the opposite posture: fail closed. If the map isn't ready, the pod doesn't pass readiness. If a service account token's version is behind the current version in the map, it's denied.&lt;/p&gt;

&lt;p&gt;Same underlying shape — in-memory map fed asynchronously from Redis — but different risk tolerance based on what's being protected.&lt;/p&gt;




&lt;h2&gt;
  
  
  How all the invalidation channels fit together
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyfd02hlh7ch4yhpkeegs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyfd02hlh7ch4yhpkeegs.png" alt=" " width="800" height="879"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three patterns across the stack:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TTL-based&lt;/strong&gt; (JWT verify cache). Simple, no coordination. Best when the cached value has a natural expiry built into it — which JWTs do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Push-based&lt;/strong&gt; (trie, revocation stream, SA version). Required when a staleness window has real cost. Needs a degraded-mode plan for when the push channel is unavailable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capacity-based eviction&lt;/strong&gt; (route cache, JWT cache). Bounded memory by design. What gets evicted matters more than when — which is why TinyLFU beats LRU for this workload.&lt;/p&gt;

&lt;p&gt;When in doubt, start with TTL. Push-based caches are powerful but bring failure modes — lost events, stalled consumers, cursor races. Use them only when a TTL window is genuinely unacceptable.&lt;/p&gt;




&lt;h2&gt;
  
  
  The cache we got wrong
&lt;/h2&gt;

&lt;p&gt;Our first JWT cache used a plain Go map with a mutex and a &lt;code&gt;time.AfterFunc&lt;/code&gt; per entry to handle expiry.&lt;/p&gt;

&lt;p&gt;It worked in tests. It fell over in production within a week. Two problems:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Goroutine pressure.&lt;/strong&gt; Every cached token spawned a timer goroutine. At a million live tokens, the Go scheduler handled it — but GC pauses got ugly and unpredictable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No cap.&lt;/strong&gt; There was no size limit. Memory grew until pods OOM-killed.&lt;/p&gt;

&lt;p&gt;Switching to Ristretto solved both: timers are amortized into a small internal worker, and &lt;code&gt;MaxCost&lt;/code&gt; enforces a hard ceiling.&lt;/p&gt;

&lt;p&gt;The lesson: a cache is a copy of state. If there's no mechanism to bound or invalidate it — TTL, push, or capacity — it's not a cache. It's a memory leak.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cold start vs. warm
&lt;/h2&gt;

&lt;p&gt;A pod's first requests are slower. The trie loads from Postgres before readiness flips — that's the only DB call in the pod's lifetime. After that, every lookup is in-memory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn26nomvqff80sqslasvd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn26nomvqff80sqslasvd.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The JWT cache starts empty on a fresh deploy and fills up within seconds as real tokens come through. We don't pre-warm it — the cost of cold RSA verifications for a few seconds after a deploy is acceptable.&lt;/p&gt;

&lt;p&gt;The revocation cache we &lt;em&gt;do&lt;/em&gt; pre-warm, synchronously, before readiness. A pod that's marked ready must have the current revocation set. Otherwise it would fail-open on every request until its first Redis sync — meaning any logouts from the past hour would be invisible to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to actually graph
&lt;/h2&gt;

&lt;p&gt;For each cache, the metrics that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hit rate&lt;/strong&gt; — the most important number. A cache with a stable size but falling hit rate is broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eviction rate&lt;/strong&gt; — meaningful only if the cache is bounded. High eviction with high hit rate is fine; it means the cache is doing its job under pressure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size&lt;/strong&gt; — useful for capacity planning, not for alerting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The JWT verify cache runs at 95%+ hit rate in steady state. A fresh deploy drops it to zero and it climbs back within seconds. Anything else warrants investigation.&lt;/p&gt;

&lt;p&gt;Don't alert on cache size. Alert on hit rate.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: Chapter 9 — operating the gateway. The structured auth decision log, OpenTelemetry tracing, the three Kubernetes probes, degraded-mode behavior, and the Slack alert pattern that keeps on-call sane during a Redis outage.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>performance</category>
      <category>caching</category>
      <category>go</category>
      <category>redis</category>
    </item>
    <item>
      <title>Part 7 — Token Revocation Without Killing Performance</title>
      <dc:creator>Akarshan Gandotra</dc:creator>
      <pubDate>Mon, 04 May 2026 18:41:57 +0000</pubDate>
      <link>https://dev.to/akarshan/token-revocation-without-killing-performance-389d</link>
      <guid>https://dev.to/akarshan/token-revocation-without-killing-performance-389d</guid>
      <description>&lt;p&gt;JWTs have a hard problem hiding inside them: they're stateless. The whole point of a JWT is that the verifier can check a signature and make a decision — no database, no round-trip. That's what makes them fast. It's also what makes "log this user out &lt;em&gt;right now&lt;/em&gt;" not work out of the box.&lt;/p&gt;

&lt;p&gt;We had to solve this. Users log out. Admins disable accounts. Service accounts rotate. Each one of those events has to invalidate live tokens &lt;em&gt;immediately&lt;/em&gt;, not at the next expiry tick.&lt;/p&gt;

&lt;p&gt;This post is about how we did it without giving up the performance properties that made JWTs worth using in the first place.&lt;/p&gt;




&lt;h2&gt;
  
  
  The constraints that ruled out the obvious answers
&lt;/h2&gt;

&lt;p&gt;Three numbers shape the design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;50,000 RPS&lt;/strong&gt; of authenticated requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sub-millisecond&lt;/strong&gt; auth budget on the hot path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-digit-second&lt;/strong&gt; propagation — when a user logs out, every pod must know within a few seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The obvious approaches each break one of these:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query Redis on every request.&lt;/strong&gt; Adds a network round-trip to every auth decision. Median latency explodes. Redis also becomes a hard single point of failure — if it's slow or down, every request fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Push revocation events via websockets or long-poll to every pod.&lt;/strong&gt; Works at low scale. Gets fragile when pods churn, restart, or drop events during a network blip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short-lived tokens with fast refresh.&lt;/strong&gt; A 5-minute expiry reduces the window, but doesn't close it — and 5 minutes is too long when an account is disabled for a security reason.&lt;/p&gt;

&lt;p&gt;What worked: a two-layer design.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Redis&lt;/strong&gt; is the &lt;em&gt;propagation&lt;/em&gt; layer. It holds the authoritative revocation state and a live event feed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local memory&lt;/strong&gt; is the &lt;em&gt;decision&lt;/em&gt; layer. Each pod keeps an in-memory map of revoked JTIs. The hot-path check is a single map lookup — no I/O.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcomxz3ztl0aaevfjh6e2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcomxz3ztl0aaevfjh6e2.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Two Redis structures, one job each
&lt;/h2&gt;

&lt;p&gt;Two Redis keys do the heavy lifting, and they serve different purposes — which is why both are necessary.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;revoked_access_tokens&lt;/code&gt; is a sorted set. Each member is a JTI; the score is the token's expiry timestamp. This is the &lt;strong&gt;source of truth at any point in time&lt;/strong&gt; — you can ask it "give me everything currently revoked" with a single range query.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;revoked_access_token_events&lt;/code&gt; is a stream. Each entry carries the JTI, expiry, and metadata about the revocation. This is the &lt;strong&gt;live feed&lt;/strong&gt; — pods subscribe to it and learn about new revocations as they happen.&lt;/p&gt;

&lt;p&gt;The ZSET answers "what is the state right now?" The Stream answers "what has changed since I last checked?" You need both because they're good at different things: the ZSET is for bulk reads at startup, the Stream is for incremental updates during steady state.&lt;/p&gt;




&lt;h2&gt;
  
  
  The startup problem — and two races hiding in the obvious solution
&lt;/h2&gt;

&lt;p&gt;When a pod boots, it needs to populate its local map before it serves traffic. The tempting approach: read the ZSET to get current revocations, then subscribe to the Stream for updates.&lt;/p&gt;

&lt;p&gt;Two races hide here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Race 1:&lt;/strong&gt; What if a revocation arrives between the ZSET read and the Stream subscription? The event is in the Stream, but the pod's cursor is positioned &lt;em&gt;after&lt;/em&gt; it. The JTI never makes it into the local cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Race 2:&lt;/strong&gt; What if you start the Stream consumer from the very beginning (&lt;code&gt;0-0&lt;/code&gt;) to avoid missing anything? Now you replay every event ever emitted — potentially thousands. Worse: if the stream has been trimmed, you'll silently miss events older than the trim window.&lt;/p&gt;

&lt;p&gt;The fix is to reverse the order: capture the Stream tip &lt;em&gt;before&lt;/em&gt; reading the ZSET, then start the consumer from that captured tip.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;TokenRevocationService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;WarmCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// 1. Capture the stream tip first.&lt;/span&gt;
    &lt;span class="n"&gt;tipID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;captureStreamTip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// 2. Read the current ZSET snapshot.&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unix&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ZRangeByScoreWithScores&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"revoked_access_tokens"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ZRangeBy&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Min&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FormatInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Max&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"+inf"&lt;/span&gt;&lt;span class="p"&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;Result&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localCache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Member&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c"&gt;// 3. Start the consumer at the tip captured before the ZSET read.&lt;/span&gt;
    &lt;span class="c"&gt;// Anything that arrived between tipID and now replays through the consumer.&lt;/span&gt;
    &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consumeStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tipID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters precisely. By capturing the tip first, anything that arrives while we're reading the ZSET will replay through the consumer. Anything already in the ZSET when we read it is loaded directly. If the same JTI appears in both — a revocation that landed right on the boundary — setting the same map entry twice is harmless.&lt;/p&gt;

&lt;p&gt;The pod's lifecycle from boot to steady state:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx1nlfcs66n8h4hrbsqmh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx1nlfcs66n8h4hrbsqmh.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The hot path: deliberately boring
&lt;/h2&gt;

&lt;p&gt;The actual check on every request is about as simple as it gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;TokenRevocationService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;IsJTIRevoked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RLock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localCache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RUnlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&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;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unix&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;expiresAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A read lock, a map lookup, a comparison. No Redis, no network. Hundreds of nanoseconds.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;!found → false&lt;/code&gt; branch is a deliberate &lt;em&gt;fail-open&lt;/em&gt; choice: if a JTI isn't in the local cache, we treat it as not revoked. The risk is that a freshly revoked token might be accepted for the few seconds between the revocation being published and the local cache being updated. We accept that window. The alternative — failing closed — would mean denying every request whose JTI we haven't explicitly loaded, which at startup means denying all traffic until the cache is fully warm. That's worse.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gap probe: catching what the Stream misses
&lt;/h2&gt;

&lt;p&gt;The Stream consumer keeps a cursor — the ID of the last event it processed. Periodically, the stream gets trimmed to bound its size. If the consumer's cursor falls behind the trim window (because of a slow handler, a GC pause, or a network blip), the next &lt;code&gt;XREAD&lt;/code&gt; will silently skip the trimmed events.&lt;/p&gt;

&lt;p&gt;We detect this with a gap probe that runs every 5 minutes:&lt;/p&gt;

&lt;p&gt;If the oldest event currently in the Stream is &lt;em&gt;newer&lt;/em&gt; than the consumer's cursor, we missed something. When that happens, we resync from the ZSET (which is the authoritative source of truth and doesn't get trimmed the same way) and snap the cursor to the stream tip.&lt;/p&gt;

&lt;p&gt;This probe has fired exactly twice in production since we added it — both times during planned Redis maintenance — and both times the recovery was automatic. The value isn't that it fires often. It's that without it, you'd never know you missed events at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Service accounts: the same idea, different risk tolerance
&lt;/h2&gt;

&lt;p&gt;User token revocation fails open — a freshly revoked token might slip through for a few seconds. That's acceptable: the window is small, bounded, and observable.&lt;/p&gt;

&lt;p&gt;Service-account rotation fails &lt;em&gt;closed&lt;/em&gt;. When a service account is rotated, the old credentials must be denied immediately, even if that means a slightly degraded startup path.&lt;/p&gt;

&lt;p&gt;The mechanism is different too: instead of JTI revocation, service accounts carry a version number. The gateway keeps a local map of current SA versions loaded from Redis. If the token's version is less than the current version for that service account, it's denied.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe6v73iq3eigxkbggpy4q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe6v73iq3eigxkbggpy4q.png" alt=" " width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pod won't pass readiness until this cache is loaded. If Redis is unavailable at startup, the pod doesn't serve traffic. That's intentional — we'd rather have fewer pods than pods that can't correctly enforce SA rotation.&lt;/p&gt;

&lt;p&gt;The 60-second sync window is our exposure. We reduce the effective risk by having the rotating system hold the old version live for a grace period, only promoting the new version once enough gateways have synced.&lt;/p&gt;




&lt;h2&gt;
  
  
  What revocation doesn't do
&lt;/h2&gt;

&lt;p&gt;A few things that seem natural but aren't in scope:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revoke by user ID.&lt;/strong&gt; The cache is JTI-indexed. To revoke all of a user's tokens, the issuer enumerates their live JTIs and revokes each one. The Auth Service sees only individual JTIs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-region propagation.&lt;/strong&gt; We run regional auth services with regional Redis instances. Revocations published in one region don't automatically appear in another. Most revocations are tenant-bound, and tenants are region-bound, so this rarely matters in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared Redis.&lt;/strong&gt; This Redis instance is auth-only. The corner cases in revocation are complex enough that sharing infrastructure with rate limiters or session stores would make debugging much harder.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we'd do differently on day one
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Add the gap probe immediately.&lt;/strong&gt; It's a small amount of code and it's the difference between "we silently lose a logout event occasionally" and "we always know when propagation breaks."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the warm path with a slow or unavailable Redis.&lt;/strong&gt; Most bugs we found were in error handling during startup, not steady-state operation. The warm path runs once per pod lifetime; staging rarely exercises it unless you deliberately inject failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bound everything from the start.&lt;/strong&gt; The local cache, the stream length, the sync interval. Unbounded growth in any of them becomes an incident.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: Chapter 8 — every cache in the hot path, together. JWT verify cache, RSA key cache, route cache, policy bitmap, revocation map, SA version map. Each one is fast individually; together they're how the gateway fits inside its latency budget. We'll cover TTL strategy, invalidation, and the one cache where we got eviction wrong.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>redis</category>
      <category>jwt</category>
      <category>performance</category>
      <category>go</category>
    </item>
    <item>
      <title>Part 6 — Authorization at Scale: Access Levels, Roles, and Compact Decisions</title>
      <dc:creator>Akarshan Gandotra</dc:creator>
      <pubDate>Mon, 04 May 2026 18:41:42 +0000</pubDate>
      <link>https://dev.to/akarshan/authorization-at-scale-access-levels-roles-and-compact-decisions-57ga</link>
      <guid>https://dev.to/akarshan/authorization-at-scale-access-levels-roles-and-compact-decisions-57ga</guid>
      <description>&lt;p&gt;Authentication answers "who are you?" Authorization answers the harder question: "are you allowed to do this?"&lt;/p&gt;

&lt;p&gt;By the time a request reaches this stage, we've already validated the token and confirmed the tenant. Now we need to decide — before the request touches any upstream service — whether this specific identity has permission to call this specific endpoint. That decision runs hundreds of millions of times a day. It needs to be fast, correct, and cheap to reason about when something goes wrong.&lt;/p&gt;

&lt;p&gt;This post is about the model we use, the simpler approach that served us for a year, and the optimization we eventually built — and why we kept the old path around anyway.&lt;/p&gt;




&lt;h2&gt;
  
  
  The model: three layers, one question
&lt;/h2&gt;

&lt;p&gt;Our authorization model has three layers:&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;role&lt;/strong&gt; is what a user is granted — something like &lt;code&gt;clinic_admin&lt;/code&gt; or &lt;code&gt;billing_specialist&lt;/code&gt;. An &lt;strong&gt;access level&lt;/strong&gt; is a coarse permission — something like &lt;code&gt;user:admin&lt;/code&gt; or &lt;code&gt;schedule:write&lt;/code&gt;. An &lt;strong&gt;endpoint&lt;/strong&gt; declares which access levels are sufficient to reach it.&lt;/p&gt;

&lt;p&gt;Roles are bags of access levels. Endpoints are protected by lists of access levels. A user can call an endpoint if any of their roles' access levels appear in the endpoint's required list. That's the whole model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1hm0cla1frifq7ocjye0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1hm0cla1frifq7ocjye0.png" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We deliberately keep it coarse. There are dozens of access levels in the system, not thousands. Questions like "can this user delete &lt;em&gt;this specific patient record&lt;/em&gt;?" belong to the upstream service that owns that data — it has the context the gateway doesn't. The gateway's job is to filter on the order of "is this user even an admin at all?" — a check that catches the vast majority of misuse and runs at edge speeds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who defines access levels and endpoints?
&lt;/h2&gt;

&lt;p&gt;Here's something that might seem surprising: the gateway doesn't own the access level definitions. &lt;strong&gt;Individual product services do.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each service ships a &lt;code&gt;access_levels.json&lt;/code&gt; alongside its code. This file declares what access levels it recognizes and which endpoints require which levels. A scheduling service owns &lt;code&gt;schedule:write&lt;/code&gt;. A billing service owns &lt;code&gt;billing:read&lt;/code&gt;. The gateway is a consumer — it doesn't make editorial decisions about what permissions mean.&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;access_levels.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;owned&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;maintained&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;by&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;upstream&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;service&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;"access_levels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"schedule:write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Create and modify appointments"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"schedule:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"View appointments"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"endpoints"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/appointments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"requires"&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;"schedule:write"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/appointments/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"requires"&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;"schedule:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"schedule:write"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="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;The publish flow runs through CI/CD. When a service merges a change to its access level definitions, a pipeline step pushes the updated file to a well-known S3 path. The gateway picks up the change on its next refresh cycle — no gateway deploy required, no manual registry edits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;service-repo/
  access_levels.json   ← owned by the service team
  .github/workflows/publish.yml

# publish.yml (simplified)
- name: Publish access levels
  run: |
    aws s3 cp access_levels.json \
      s3://registry/services/${{ env.SERVICE_NAME }}/access_levels.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps ownership aligned: the team that builds the feature decides what permission protects it. The gateway team owns the &lt;em&gt;mechanism&lt;/em&gt;; product teams own the &lt;em&gt;policy&lt;/em&gt;. Changes are auditable through git history, reviewable via pull request, and rollbackable the same way code is.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the token carries — and what it doesn't
&lt;/h2&gt;

&lt;p&gt;The JWT includes the user's granted access levels as a bitmap — a compact byte slice — along with the version of the registry used when the token was issued. It does &lt;em&gt;not&lt;/em&gt; contain the full permission graph, and it does not contain endpoint requirements. Those live in the database, loaded into memory at boot.&lt;/p&gt;

&lt;p&gt;A decoded JWT payload looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tenant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acme-health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"policy_bitmap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"__________________8H"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"policy_bitmap_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;114&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1714000000&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;policy_bitmap&lt;/code&gt; is a base64url-encoded byte slice — each bit position corresponds to one access level in the registry at version &lt;code&gt;114&lt;/code&gt;. &lt;code&gt;policy_bitmap_version&lt;/code&gt; tells the gateway exactly which registry snapshot to use when interpreting the bits. If the gateway's current registry is at version 114, it uses the fast bitmap path. If the versions differ, it falls back to string matching (more on that below).&lt;/p&gt;

&lt;p&gt;This is a deliberate tradeoff. The stateless alternative — put everything in the token, make every decision without a database — sounds clean until users accumulate permissions. Tokens balloon to 4–8 KB. Cookies start failing at network edges. Mobile clients cache tokens aggressively and get stuck with stale permission sets. Every role change requires re-issuing every affected token immediately.&lt;/p&gt;

&lt;p&gt;The compromise: the JWT carries &lt;em&gt;coarse access levels&lt;/em&gt; (a small, stable set encoded as a bitmap), and the database carries &lt;em&gt;endpoint requirements&lt;/em&gt; (queried once at startup, refreshed on demand). Per-request authorization is a fast in-memory lookup on both sides.&lt;/p&gt;

&lt;p&gt;The payoff on token size is significant. Before the bitmap rework, heavily-permissioned admins had tokens approaching 3 KB. After:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0fytx9d69ueqgcg5jl3d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0fytx9d69ueqgcg5jl3d.png" alt=" " width="800" height="381"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The original approach: string matching
&lt;/h2&gt;

&lt;p&gt;The first implementation is what you'd sketch on a whiteboard. Take the user's access levels, take the endpoint's required access levels, check if they overlap.&lt;/p&gt;

&lt;p&gt;It's &lt;code&gt;O(n + m)&lt;/code&gt; — linear in the number of user permissions and required permissions. With typical values (a user might have 20–80 access levels, an endpoint usually requires 1–3), this runs in nanoseconds. It's correct, it's readable, and it worked fine in production for over a year.&lt;/p&gt;

&lt;p&gt;The reason we eventually replaced it had nothing to do with speed.&lt;/p&gt;

&lt;p&gt;The first reason was &lt;strong&gt;token size&lt;/strong&gt;. As the platform grew and senior users accumulated more access levels, tokens stretched. We had admins with tokens approaching 3 KB. That's uncomfortable but manageable — until it isn't.&lt;/p&gt;

&lt;p&gt;The second reason was &lt;strong&gt;density of signal&lt;/strong&gt;. String matching tells you &lt;em&gt;that&lt;/em&gt; the user was authorized, but the log entry just says &lt;code&gt;granted: ["user:admin"]&lt;/code&gt;. We wanted richer per-permission metrics — which access levels are actually being exercised, which ones are granted but never hit anything — without adding another pass over the data.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bitmap approach: compress the representation, keep the logic
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4jburp4a0hiw10dpseif.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4jburp4a0hiw10dpseif.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The idea is simple: assign every access level a stable integer index. Represent a user's granted permissions as a bit vector — one bit per access level. Represent each endpoint's requirements the same way. Authorization becomes a bitwise AND.&lt;/p&gt;

&lt;p&gt;If the result is nonzero, the user has at least one of the required permissions. Allow. If the result is zero, deny. That's the entire hot path.&lt;/p&gt;

&lt;p&gt;The anchoring snippet — the intersection check at the core of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;intersects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&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;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&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;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&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="no"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a typical 32-byte bitmap (covering 256 possible access levels), this is a handful of CPU instructions. Decision time dropped from ~3 microseconds in the worst legacy case to under 200 nanoseconds. Not visible to end users. Very visible in CPU costs at 50,000 requests per second.&lt;/p&gt;

&lt;p&gt;Token size dropped too — from ~3 KB for a heavily-permissioned admin to under 1 KB. The access levels that used to be a long string array became the &lt;code&gt;policy_bitmap&lt;/code&gt; field: a base64url-encoded byte slice.&lt;/p&gt;

&lt;p&gt;The two paths side by side:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqupwdqmu5xzwgxivnwua.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqupwdqmu5xzwgxivnwua.png" alt=" " width="800" height="843"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The version problem — and why we kept the old path
&lt;/h2&gt;

&lt;p&gt;Here's the catch: bit indexes have to be stable. If access level &lt;code&gt;user:admin&lt;/code&gt; is bit 0 today, it must still be bit 0 when old tokens are being validated. This is managed through a versioned registry — each snapshot of the bit assignments carries a version number, and the JWT records which version was used when it was issued via &lt;code&gt;policy_bitmap_version&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"policy_bitmap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"__________________8H"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"policy_bitmap_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;114&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;When the gateway boots, it loads the current registry — say, version 114 — and builds an in-memory lookup from version number to bit-index map. When a token arrives, the gateway reads &lt;code&gt;policy_bitmap_version&lt;/code&gt; and checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Version matches current registry (114 == 114):&lt;/strong&gt; decode the bitmap, run &lt;code&gt;intersects()&lt;/code&gt;, done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version is older (e.g., 112):&lt;/strong&gt; fall back to string matching against the access level names.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;policy_bitmap_version&lt;/code&gt; field:&lt;/strong&gt; legacy token predating the bitmap feature — fall back to string matching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fallback uses the access level names embedded in the token (carried as a separate claim for exactly this purpose) and checks them against the endpoint's required list. Same outcome, no bitmap needed.&lt;/p&gt;

&lt;p&gt;This fallback isn't a temporary measure. It's load-bearing. Long-lived service account tokens might be weeks old. We can't deny them just because they predate a registry update. The string-based check is version-agnostic: it doesn't care about bit indexes at all. As long as both sides agree on what the access level &lt;em&gt;strings&lt;/em&gt; mean, it works.&lt;/p&gt;

&lt;p&gt;New registry versions are created whenever a service publishes new access level definitions through the CI/CD pipeline. The version number increments, new bit positions are assigned to new access levels, and existing assignments are preserved verbatim. Old tokens stay valid — they just take the slightly slower path until they expire naturally.&lt;/p&gt;

&lt;p&gt;We track the fallback rate with a metric. When it's near zero, things are healthy. A spike tells us something is wrong — maybe a token issuer is behind on registry versions, maybe a test fixture has stale data, or maybe a new service published access levels without updating the issuer to match.&lt;/p&gt;




&lt;h2&gt;
  
  
  A few things we deliberately didn't do
&lt;/h2&gt;

&lt;p&gt;Some approaches we considered and rejected:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caching the authorization decision.&lt;/strong&gt; "Same token plus same endpoint equals same answer" feels right. It's wrong — role changes, revocation, and tenant changes all invalidate it. We cache the &lt;em&gt;token decode result&lt;/em&gt; (the identity and access levels), not the &lt;em&gt;decision&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-tenant access level definitions.&lt;/strong&gt; Letting each tenant define what &lt;code&gt;user:admin&lt;/code&gt; means sounds flexible. In practice, it means the registry forks and every cross-tenant reasoning breaks. Access levels are platform-wide; role assignments are per-tenant. That's the line. Individual services define access levels globally — they don't get per-tenant variants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hierarchical permissions.&lt;/strong&gt; "user:admin implies user:read" is elegant on paper. It complicates bitmap encoding and makes rollback harder. We grant both explicitly. A few extra access levels per role is not a real cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A central registry team as the bottleneck.&lt;/strong&gt; Early on, a single team owned all access level definitions. This created a queue — every new feature needed a registry PR to land before it could ship. Moving ownership to service teams via the CI/CD publish flow eliminated that queue entirely. The gateway team reviews the mechanism; service teams review each other's access level semantics in their own PRs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The pattern underneath the optimization
&lt;/h2&gt;

&lt;p&gt;The bitmap is a performance and density win. But the deeper idea is the same one from the last chapter: make the implicit explicit and keep the decision structure visible.&lt;/p&gt;

&lt;p&gt;String matching and bitmap intersection both produce the same outcome — allow or deny. What the bitmap adds isn't correctness, it's &lt;em&gt;compactness&lt;/em&gt;: a cheaper wire representation, a faster runtime check, and a version-aware fallback that degrades gracefully instead of breaking.&lt;/p&gt;

&lt;p&gt;The CI/CD publish flow adds a different kind of compactness: it removes the coordination overhead of centralized registry management. Services declare what they need. The pipeline handles the distribution. The gateway consumes whatever's in the registry. No tickets, no handoffs.&lt;/p&gt;

&lt;p&gt;The fallback is worth lingering on. Most optimizations in auth systems are irreversible — once you commit to a new token format, old tokens become a problem. Keeping the legacy path as a first-class citizen, with its own metrics and log fields, meant we could ship the optimization without a flag day. Old tokens kept working. New tokens got faster. The two paths converged over time on their own.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the deployment actually looked like
&lt;/h2&gt;

&lt;p&gt;Theory is one thing. Here's the Datadog dashboard from the bitmap deployment on Apr 27 at 17:30.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Favcvw6wzy5cusyvnblku.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Favcvw6wzy5cusyvnblku.png" alt="Datadog dashboard showing request hits, error rate, p99 latency, and execution time breakdown across the bitmap deployment boundary" width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The real win shows up in p99 latency: it drops from a spiky 5–14 ms pattern to a stable ~4–5 ms, eliminating GC-induced variance from string allocations.&lt;/p&gt;

&lt;p&gt;Execution time stayed in the 100–400 µs range, with a one-time spike during in-memory bitmap rebuild. Fallback usage naturally decayed as tokens rotated, validating seamless migration.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: Chapter 7 — token revocation. JWTs are stateless by design, which makes "log this user out right now" genuinely hard. We solved it with a Redis-backed revocation list, an in-process cache, and two startup races we had to fix the painful way.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>authorization</category>
      <category>performance</category>
      <category>go</category>
      <category>auth</category>
    </item>
    <item>
      <title>Part 5 — Multi-tenant auth and routing in Kubernetes</title>
      <dc:creator>Akarshan Gandotra</dc:creator>
      <pubDate>Mon, 04 May 2026 18:41:26 +0000</pubDate>
      <link>https://dev.to/akarshan/multi-tenant-auth-and-routing-in-kubernetes-o85</link>
      <guid>https://dev.to/akarshan/multi-tenant-auth-and-routing-in-kubernetes-o85</guid>
      <description>&lt;p&gt;In the first four chapters of this series I've talked about &lt;em&gt;what&lt;/em&gt; the Auth Gateway decides. This chapter is about &lt;em&gt;who&lt;/em&gt; it decides for.&lt;/p&gt;

&lt;p&gt;We run a multi-tenant platform. Every request, on every endpoint, belongs to one tenant. Get tenant resolution wrong and you don't have a security incident — you have a &lt;em&gt;cross-tenant data leak&lt;/em&gt; incident, which is a category of bad you don't recover from.&lt;/p&gt;

&lt;p&gt;This chapter is the boring, careful, paranoid story of how NGINX and the Auth Service cooperate to never let a request through without a clear tenant identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two questions
&lt;/h2&gt;

&lt;p&gt;Every multi-tenant request raises two questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which tenant is this for?&lt;/strong&gt; (resolution)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where does the request go for that tenant?&lt;/strong&gt; (routing)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We answer #1 at the NGINX layer, before auth. We answer #2 partly at NGINX (path-based routing) and partly inside the upstream service (tenant-scoped queries). The Auth Service sits between them: it makes sure the &lt;em&gt;token's&lt;/em&gt; tenant matches the &lt;em&gt;request's&lt;/em&gt; tenant before either service sees the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolution: two valid inputs, one explicit failure mode
&lt;/h2&gt;

&lt;p&gt;We accept two ways to identify a tenant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Tenant-ID&lt;/code&gt; header.&lt;/strong&gt; Explicit. Used by service-to-service calls and SDKs that know who they're for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Host header (mapped via &lt;code&gt;X-Tenant-Host&lt;/code&gt;).&lt;/strong&gt; Implicit. Used by per-tenant DNS like &lt;code&gt;tenant1.example.com&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We do &lt;em&gt;not&lt;/em&gt; accept a third way: a default tenant. There is no fallback. If both inputs are missing or unknown, NGINX returns 400 &lt;em&gt;before&lt;/em&gt; the Auth Service is even called.&lt;/p&gt;

&lt;p&gt;Why so strict? Because a default tenant is the most expensive bug you can ship. Every "wait why is data showing up in tenant X?" post-mortem starts the same way: somebody added a fallback "for convenience" and somebody else's request hit it without a tenant header.&lt;/p&gt;

&lt;p&gt;We removed our default tenant on day 90 and have never looked back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj7d2g7jz5npr8b94rhri.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj7d2g7jz5npr8b94rhri.png" alt=" " width="800" height="597"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-tenant SNI server blocks
&lt;/h2&gt;

&lt;p&gt;For tenants with their own DNS, NGINX uses &lt;em&gt;server blocks&lt;/em&gt; to short-circuit resolution. The Helm chart templates one server per tenant from &lt;code&gt;global.tenants&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="err"&gt;{{&lt;/span&gt;&lt;span class="k"&gt;-&lt;/span&gt; &lt;span class="s"&gt;range&lt;/span&gt; &lt;span class="s"&gt;.Values.global.tenants&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;
&lt;span class="s"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; $&lt;span class="kn"&gt;.Values.containers.containerPort&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;.tenant_dns&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$tenant_id&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;.tenant_id&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$tenant_namespace&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;.tenant_namespace&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/auth.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/locations.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/custom_error_locations.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;{{&lt;/span&gt;&lt;span class="kn"&gt;-&lt;/span&gt; &lt;span class="s"&gt;end&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A request to &lt;code&gt;tenant1.example.com&lt;/code&gt; matches &lt;code&gt;server_name tenant1.example.com&lt;/code&gt;, lands in this block, and &lt;code&gt;$tenant_id&lt;/code&gt; is &lt;em&gt;already&lt;/em&gt; set before any other directive runs. There is no header parsing, no map lookup, no opportunity for ambiguity. Tenant identity is pinned at SNI time.&lt;/p&gt;

&lt;p&gt;This is also nice for TLS: the per-tenant ingress can attach per-tenant certificates if you want them, and the SNI selection happens before any HTTP processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default server block: header-based fallback
&lt;/h2&gt;

&lt;p&gt;Not every tenant has its own DNS. Many service-to-service calls hit a shared in-cluster ingress with &lt;code&gt;X-Tenant-ID&lt;/code&gt; set explicitly. For those, the default server block handles resolution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;.Values.containers.containerPort&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                            &lt;span class="c1"&gt;# match anything not matched above&lt;/span&gt;

  &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$tenant_id&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$http_x_tenant_id&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$tenant_id&lt;/span&gt; &lt;span class="nv"&gt;$http_x_tenant_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;# priority 1&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenant_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$tenant_id&lt;/span&gt; &lt;span class="nv"&gt;$tenant_id_from_host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;# priority 2: map of X-Tenant-Host&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenant_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="s"&gt;"Tenant&lt;/span&gt; &lt;span class="s"&gt;not&lt;/span&gt; &lt;span class="s"&gt;specified"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# priority 3: hard fail&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/auth.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/locations.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/custom_error_locations.conf&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;&lt;code&gt;$tenant_id_from_host&lt;/code&gt; is a &lt;code&gt;map&lt;/code&gt; populated from the same &lt;code&gt;global.tenants&lt;/code&gt; list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$http_x_tenant_host&lt;/span&gt; &lt;span class="nv"&gt;$tenant_id_from_host&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;{{&lt;/span&gt;&lt;span class="kn"&gt;-&lt;/span&gt; &lt;span class="s"&gt;range&lt;/span&gt; &lt;span class="s"&gt;.Values.global.tenants&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;
  &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;.tenant_dns&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;.tenant_id&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="err"&gt;{{&lt;/span&gt;&lt;span class="kn"&gt;-&lt;/span&gt; &lt;span class="s"&gt;end&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few subtleties worth highlighting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The order matters. Header beats host. We picked header priority because programmatic clients should be explicit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$tenant_id_from_host&lt;/code&gt; defaults to empty string if the host isn't in the map. We then 400 — same as if the header was missing entirely.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;if&lt;/code&gt; directives in NGINX are deeply weird. We confined them to this block and resisted the temptation to put &lt;code&gt;if&lt;/code&gt;s anywhere else in the config.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tenant binding inside the JWT
&lt;/h2&gt;

&lt;p&gt;Once NGINX has set &lt;code&gt;$tenant_id&lt;/code&gt;, it forwards it as &lt;code&gt;X-Tenant-ID&lt;/code&gt; to the Auth Service. But the &lt;em&gt;token&lt;/em&gt; also carries a tenant claim. The Auth Service must check they match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;userToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TenantID&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TenantID&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReasonTenantMismatch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"tenant mismatch"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the line that saves you when a malicious actor copies a token from tenant A and replays it against tenant B's hostname. The token signature is valid. The token isn't expired. The token isn't revoked. But the &lt;em&gt;tenant&lt;/em&gt; in the token is &lt;code&gt;tenantA&lt;/code&gt; and the request is for &lt;code&gt;tenantB&lt;/code&gt;. We 401.&lt;/p&gt;

&lt;p&gt;Three things make this work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The token is bound to a tenant at issuance.&lt;/strong&gt; Our token issuer puts &lt;code&gt;tid: "tenantA"&lt;/code&gt; in the JWT claims when it mints the token. We sign with the per-tenant RSA key (Chapter 3), so a token from tenant A &lt;em&gt;can't&lt;/em&gt; be re-signed for tenant B without the private key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gateway picks the verification key by &lt;code&gt;X-Tenant-ID&lt;/code&gt;.&lt;/strong&gt; If the request says it's for tenant B, we verify the token's signature with tenant B's public key. A tenant A token signed with tenant A's key fails signature validation, not tenant binding — but either way it's denied.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tenant claim is &lt;em&gt;also&lt;/em&gt; checked.&lt;/strong&gt; Even if the keys were the same, the explicit &lt;code&gt;userToken.TenantID != log.TenantID&lt;/code&gt; check would catch reuse.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Belt and suspenders. We've never regretted having both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sequence: tenant flow end-to-end
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F81h3msmwoogdtm0a9u7g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F81h3msmwoogdtm0a9u7g.png" alt=" " width="800" height="628"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing: MT vs ST upstreams
&lt;/h2&gt;

&lt;p&gt;Once we know the tenant, we have to route the request. We have two upstream models:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MT (multi-tenant)&lt;/strong&gt; services. One deployment, serves all tenants. Tenant comes in as &lt;code&gt;X-Tenant-ID&lt;/code&gt;, the service queries data with &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ST (single-tenant)&lt;/strong&gt; services. One deployment &lt;em&gt;per tenant&lt;/em&gt;, in the tenant's own Kubernetes namespace. The service doesn't even need to know about other tenants — it can't see them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is purely a per-service architectural choice. Some products are happy with MT; some have stricter isolation requirements (or run heavy per-tenant data) and want ST.&lt;/p&gt;

&lt;p&gt;The location loop in &lt;code&gt;locations.conf&lt;/code&gt; handles both with one branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;eq&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt; &lt;span class="s"&gt;"ST"&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$serviceDict&lt;/span&gt;&lt;span class="kn"&gt;.SERVICE_HOST&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$tenant_namespace&lt;/span&gt;&lt;span class="s"&gt;.svc.cluster.local&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;else&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$serviceDict&lt;/span&gt;&lt;span class="kn"&gt;.SERVICE_HOST&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;end&lt;/span&gt; &lt;span class="err"&gt;}}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unrolled:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MT:&lt;/strong&gt; &lt;code&gt;proxy_pass http://user-service&lt;/code&gt; — the bare service name. CoreDNS resolves it to the service's ClusterIP in whatever namespace the gateway lives in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ST:&lt;/strong&gt; &lt;code&gt;proxy_pass http://api-service.tenant1-ns.svc.cluster.local&lt;/code&gt; — the FQDN includes the tenant namespace. Each tenant has its own copy of the service in their own namespace.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyzq8hrbmm5qnhv541cgx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyzq8hrbmm5qnhv541cgx.png" alt=" " width="800" height="128"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few subtleties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For ST, the tenant namespace is &lt;em&gt;part of the DNS name&lt;/em&gt;. NGINX's resolver kicks in at request time, not at config-load time. Adding a new tenant means deploying its services in &lt;code&gt;tenantN-ns&lt;/code&gt;, then adding it to &lt;code&gt;global.tenants&lt;/code&gt;. NGINX picks it up on the next config reload.&lt;/li&gt;
&lt;li&gt;For MT, all tenants hit the same upstream IP. The upstream service is responsible for tenant scoping. We trust it because we forward &lt;code&gt;X-Tenant-ID&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; the upstream service's auth library re-checks the header against the token's tenant. (Yes, double-checking. After the first cross-tenant near-miss, we added it.)&lt;/li&gt;
&lt;li&gt;ST is more expensive operationally — N deployments of every service — but radically simpler to reason about. Two services, two answers; pick what your compliance team can defend.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Headers we propagate to upstream
&lt;/h2&gt;

&lt;p&gt;After auth passes, NGINX sends a defined set of headers to the upstream. From &lt;code&gt;locations.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Identity-ID&lt;/span&gt;       &lt;span class="nv"&gt;$identity_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Identity-Type&lt;/span&gt;     &lt;span class="nv"&gt;$identity_type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Identity-Name&lt;/span&gt;     &lt;span class="nv"&gt;$identity_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Session-ID&lt;/span&gt;        &lt;span class="nv"&gt;$session_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Tenant-ID&lt;/span&gt;         &lt;span class="nv"&gt;$tenant_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Tenant-Namespace&lt;/span&gt;  &lt;span class="nv"&gt;$tenant_namespace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Request-ID&lt;/span&gt;        &lt;span class="nv"&gt;$request_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upstream contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Identity-ID&lt;/code&gt;&lt;/strong&gt; is the principal. Treat it as the user's primary key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Identity-Type&lt;/code&gt;&lt;/strong&gt; is &lt;code&gt;USER&lt;/code&gt; or &lt;code&gt;SERVICE_ACCOUNT&lt;/code&gt;. Some endpoints reject service accounts, some require them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Tenant-ID&lt;/code&gt;&lt;/strong&gt; is the tenant. &lt;em&gt;Always&lt;/em&gt; scope queries by it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Tenant-Namespace&lt;/code&gt;&lt;/strong&gt; is the Kubernetes namespace, useful for diagnostics and per-tenant Kafka topic naming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Session-ID&lt;/code&gt;&lt;/strong&gt; is an opaque session correlation ID. Useful for logging, never for auth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Request-ID&lt;/code&gt;&lt;/strong&gt; is the trace correlation ID. Forward it to your downstream calls so the whole graph stitches together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We do &lt;em&gt;not&lt;/em&gt; forward &lt;code&gt;Authorization&lt;/code&gt;. The upstream service has no business looking at the JWT. If it needs to know who's calling, it uses &lt;code&gt;X-Identity-ID&lt;/code&gt;. If it needs to make a downstream call, it gets a fresh service-account token — it does not replay the user's token.&lt;/p&gt;

&lt;p&gt;Stripping &lt;code&gt;Authorization&lt;/code&gt; was one of those changes that everyone agrees is a great idea in principle and fights tooth-and-nail when their service breaks during the rollout. Worth the fight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skipping tenant for a few endpoints
&lt;/h2&gt;

&lt;p&gt;A handful of endpoints genuinely don't have a tenant: NGINX &lt;code&gt;/healthz&lt;/code&gt;, public OAuth callbacks, JWKS endpoints, version probes. For these, tenant resolution must &lt;em&gt;not&lt;/em&gt; run.&lt;/p&gt;

&lt;p&gt;In NGINX:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/healthz&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;access_log&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This location is matched &lt;em&gt;before&lt;/em&gt; the tenant-resolving &lt;code&gt;if&lt;/code&gt; block in the default server, because NGINX processes more-specific locations first. &lt;code&gt;/healthz&lt;/code&gt; returns 204 without ever evaluating &lt;code&gt;$tenant_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Inside the Auth Service, the equivalent pattern shows up: the trie has rows with &lt;code&gt;endpoint_type=OPEN&lt;/code&gt; and &lt;em&gt;no&lt;/em&gt; tenant requirement. Even if NGINX did pass through, the Auth Service would allow without checking the tenant. Belt and suspenders again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ingress regex: another layer of opt-in
&lt;/h2&gt;

&lt;p&gt;Our cluster's Ingress controller routes some paths through the Auth Gateway and some paths &lt;em&gt;around&lt;/em&gt; it. The chart's multi-tenant Ingress uses a negative-lookahead regex to express "send everything to NGINX &lt;em&gt;except&lt;/em&gt; these specific exempt paths":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/(?!{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$exemptedPattern&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}).*"&lt;/span&gt;
  &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ImplementationSpecific&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;$shortName&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$exemptedPattern&lt;/code&gt; is a long alternation built from &lt;code&gt;values.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;exemptedPaths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;api/&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;login/&lt;/span&gt;
  &lt;span class="na"&gt;exemptedPrefixes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ui&lt;/span&gt;
  &lt;span class="na"&gt;exemptedExtensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;js&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;css&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ico&lt;/span&gt;
    &lt;span class="c1"&gt;# ... static assets&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything matching the regex bypasses the gateway and goes to a legacy ingress. This is how we rolled the gateway out &lt;em&gt;one service at a time&lt;/em&gt; — and how we keep it manageable today as services migrate at different speeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd warn future-us about
&lt;/h2&gt;

&lt;p&gt;A few real lessons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Default tenants are forever.&lt;/strong&gt; If you ship one, every subsequent design decision will assume it exists, and removing it later is a multi-quarter project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tenant-aware logging is non-negotiable.&lt;/strong&gt; Every log line must carry &lt;code&gt;tenant_id&lt;/code&gt;. We don't grep by user — we grep by tenant first, then narrow. Chapter 9 has the log format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the tenant model boring.&lt;/strong&gt; "What is a tenant?" should have a 1-sentence answer. The moment "tenant" starts meaning different things in different services, your isolation guarantees evaporate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MT and ST are different operating models, not different security models.&lt;/strong&gt; The same auth contract should hold. If your ST services can be looser because "they're isolated anyway," you have a problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never derive tenant from the user.&lt;/strong&gt; "User belongs to tenant X, so I'll use tenant X" sounds reasonable until you have users in multiple tenants. The tenant comes from the &lt;em&gt;request&lt;/em&gt;, not from the &lt;em&gt;user&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Chapter 6 stays on tenant boundaries but zooms in on the authorization side: roles, access levels, the role → access-level → endpoint mapping, and the bitmap fast path that replaced our original string-set matching. We'll see why a JWT &lt;em&gt;should not&lt;/em&gt; be the source of truth for a user's full permission set, and how to encode permissions densely enough that the gateway can decide in O(1) bitwise.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>multitenancy</category>
      <category>nginx</category>
      <category>auth</category>
    </item>
    <item>
      <title>Part 4 — Endpoint classification: OPEN, AUTHENTICATED, ACCESS_CONTROLLED</title>
      <dc:creator>Akarshan Gandotra</dc:creator>
      <pubDate>Mon, 04 May 2026 18:41:07 +0000</pubDate>
      <link>https://dev.to/akarshan/endpoint-classification-open-authenticated-accesscontrolled-561</link>
      <guid>https://dev.to/akarshan/endpoint-classification-open-authenticated-accesscontrolled-561</guid>
      <description>&lt;p&gt;In Chapter 3 the controller branched on something called the "endpoint type":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;endpointType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;perms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"OPEN"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;            &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"AUTHENTICATED"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"ACCESS_CONTROLLED"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That branch is the most important conditional in the entire gateway. It decides whether a request even gets a token check, and whether to run authorization. This chapter is about how that decision is &lt;em&gt;data&lt;/em&gt;, not code, and the trie that powers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three kinds of endpoint
&lt;/h2&gt;

&lt;p&gt;Every endpoint in our platform falls into one of three buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OPEN&lt;/strong&gt; — no auth required at all. Health checks, public OAuth callbacks, JWKS, version, docs. The request is allowed without a token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AUTHENTICATED&lt;/strong&gt; — token required, no specific permission. "Get my own profile," logout, list-my-stuff endpoints. Anyone with a valid token can call it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ACCESS_CONTROLLED&lt;/strong&gt; — token required &lt;em&gt;and&lt;/em&gt; a specific permission. Admin operations, deletes, anything that crosses a user boundary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Auth Service runs different pipelines for each:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxsyx5cxwqlu0wvj33e67.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxsyx5cxwqlu0wvj33e67.png" alt=" " width="800" height="539"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The brilliance — and we say this honestly, because we did &lt;em&gt;not&lt;/em&gt; design it this way the first time — is that an endpoint's classification is a column in a database row, not a hardcoded route. Adding a new admin route means inserting a row, not deploying the gateway. We rebuild the in-memory data structure on demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data structure: a trie
&lt;/h2&gt;

&lt;p&gt;When NGINX hits &lt;code&gt;/auth&lt;/code&gt;, it forwards &lt;code&gt;X-Original-URI: /user-management/v1/users/abc123&lt;/code&gt; and &lt;code&gt;X-Original-Method: GET&lt;/code&gt;. We need to turn that into an &lt;em&gt;endpoint metadata record&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The naïve approach is a big map with &lt;code&gt;(method, full_path)&lt;/code&gt; keys. That works until you have wildcards: &lt;code&gt;/users/{id}&lt;/code&gt; should match both &lt;code&gt;/users/abc&lt;/code&gt; and &lt;code&gt;/users/xyz&lt;/code&gt;. Once you have wildcards you want a trie.&lt;/p&gt;

&lt;p&gt;Our trie node looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;TrieNode&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;children&lt;/span&gt;   &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;TrieNode&lt;/span&gt;  &lt;span class="c"&gt;// exact-match segments&lt;/span&gt;
    &lt;span class="n"&gt;wildcard&lt;/span&gt;   &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;TrieNode&lt;/span&gt;             &lt;span class="c"&gt;// catch-all child for {id}-style segments&lt;/span&gt;
    &lt;span class="n"&gt;Permissions&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;][]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;  &lt;span class="c"&gt;// method -&amp;gt; required permissions&lt;/span&gt;
    &lt;span class="n"&gt;EndpointType&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;               &lt;span class="c"&gt;// OPEN | AUTHENTICATED | ACCESS_CONTROLLED&lt;/span&gt;
    &lt;span class="n"&gt;BitmapMask&lt;/span&gt;   &lt;span class="kt"&gt;uint64&lt;/span&gt;               &lt;span class="c"&gt;// pre-computed for bitmap fast-path (Chapter 6)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A trie key is a slash-segmented path. &lt;code&gt;/users/{id}/roles&lt;/code&gt; becomes the path &lt;code&gt;["users", "{id}", "roles"]&lt;/code&gt;. Walking the trie is one segment at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Trie&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&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;TrieNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;path&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;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;
            &lt;span class="k"&gt;continue&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;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wildcard&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wildcard&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Permissions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&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;O(depth)&lt;/code&gt; worst case, where depth is the number of segments. In practice a typical endpoint is 3-5 segments. We're talking nanoseconds.&lt;/p&gt;

&lt;p&gt;A toy view of what one slug's trie looks like:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdiltljlcz1nvbxdib3hq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdiltljlcz1nvbxdib3hq.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice &lt;code&gt;me&lt;/code&gt; and &lt;code&gt;{id}&lt;/code&gt; are both children of &lt;code&gt;users&lt;/code&gt;. &lt;code&gt;users/me&lt;/code&gt; resolves first (exact match) and gets &lt;code&gt;AUTHENTICATED&lt;/code&gt;. &lt;code&gt;users/abc123&lt;/code&gt; falls through to the &lt;code&gt;{id}&lt;/code&gt; branch and gets &lt;code&gt;ACCESS_CONTROLLED + user:read&lt;/code&gt;. Order matters: exact wins over wildcard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service slug: scoping the trie
&lt;/h2&gt;

&lt;p&gt;We don't have one giant trie. We have &lt;em&gt;one trie per service slug&lt;/em&gt;. Why?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Different services own different paths.&lt;/li&gt;
&lt;li&gt;A single global trie collides on ambiguous paths (multiple services exposing &lt;code&gt;/v1/users&lt;/code&gt; for different reasons).&lt;/li&gt;
&lt;li&gt;Cache locality and refresh granularity are better when each service's routes are isolated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a long time the slug came from "the first segment of the URI." That was fine until services nested each other (&lt;code&gt;/api/v2/user-management/users&lt;/code&gt;). So we added two optional headers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;X-Service-Slug&lt;/code&gt; — explicit slug.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;X-Request-Path&lt;/code&gt; — the path &lt;em&gt;inside&lt;/em&gt; that slug, with the prefix already stripped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The route resolver uses them when present:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&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;RouteResolver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ResolveEndpointWithMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;AuthDecisionLog&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trieKey&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;perms&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trieExists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="kt"&gt;bool&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="n"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Service-Slug"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Request-Path"&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;slug&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// Legacy: split URI on first segment&lt;/span&gt;
        &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;splitFirstSegment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URI&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;trie&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;globalTrieRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&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;trie&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;trie&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Permissions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two return flags, not one: &lt;code&gt;trieExists&lt;/code&gt; distinguishes "the service slug isn't registered" from "the service slug is registered but this path doesn't match." The first is a server problem (deploy mismatch). The second is a client problem (404). Different decision reasons, different alerts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading the trie from Postgres
&lt;/h2&gt;

&lt;p&gt;The source of truth is two tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Each row is one (service, method, pattern) combination&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;service_slug&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;method&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pattern&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;endpoint_type&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;  &lt;span class="c1"&gt;-- OPEN | AUTHENTICATED | ACCESS_CONTROLLED&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Permissions an endpoint requires (only for ACCESS_CONTROLLED)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;endpoint_policy&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;endpoint_id&lt;/span&gt;      &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;general_policy_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;general_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- The actual policy table — name + bit_index for the bitmap path (Chapter 6)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;general_policy&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;        &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bit_index&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;  &lt;span class="c1"&gt;-- nullable; assigned for bitmap-eligible permissions&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At startup, &lt;code&gt;LoadTrieAndRegistry&lt;/code&gt; runs one query per active service slug, joins the three tables, and builds a &lt;code&gt;Trie&lt;/code&gt; per slug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;LoadTrieAndRegistry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&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;sql&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="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;helpers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Trie&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;policybitmap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&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="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&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;QueryContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`
        SELECT e.service_slug, e.method, e.pattern, e.endpoint_type,
               coalesce(array_agg(gp.name) FILTER (WHERE gp.id IS NOT NULL), '{}'),
               coalesce(array_agg(gp.bit_index) FILTER (WHERE gp.bit_index IS NOT NULL), '{}')
          FROM endpoint e
          LEFT JOIN endpoint_policy ep ON ep.endpoint_id = e.id
          LEFT JOIN general_policy  gp ON gp.id = ep.general_policy_id
         GROUP BY e.id
    `&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;tries&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;helpers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Trie&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;snap&lt;/span&gt;  &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;policybitmap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewSnapshot&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;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;etype&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
            &lt;span class="n"&gt;permNames&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;
            &lt;span class="n"&gt;bitIdxs&lt;/span&gt;   &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;etype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;permNames&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;pq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bitIdxs&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;tries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;helpers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewTrie&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EndpointType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;etype&lt;/span&gt;
        &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Permissions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;][]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;permNames&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BitmapMask&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;computeMask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bitIdxs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// Chapter 6&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single SQL query, one pass to build N tries. We measure load time and emit it as a startup log: &lt;code&gt;trie_load_duration_ms&lt;/code&gt;. On a healthy database, hundreds of tries with thousands of routes load in well under a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refreshing without a restart
&lt;/h2&gt;

&lt;p&gt;Endpoint metadata changes — we onboard a new service, add a new permission, deprecate a route. We don't want to roll the gateway every time.&lt;/p&gt;

&lt;p&gt;There are two refresh mechanisms:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Periodic.&lt;/strong&gt; Every &lt;code&gt;TRIE_REFRESH_INTERVAL_SECS&lt;/code&gt; (default: 1 hour) we re-run the loader. This is a safety net. If the live channel ever misses an event, periodic catches it within an hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Live.&lt;/strong&gt; A Redis Pub/Sub channel called &lt;code&gt;auth:trie:refresh&lt;/code&gt;. When admin tooling changes endpoint metadata, it &lt;code&gt;PUBLISH&lt;/code&gt;es to that channel. Every Auth Service pod is subscribed, and refreshes within milliseconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuw89ibdgwobgeyaiixlq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuw89ibdgwobgeyaiixlq.png" alt=" " width="800" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Pub/Sub message itself carries no payload. It's purely a &lt;em&gt;kick&lt;/em&gt;. Each pod queries the database itself. Two reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We don't have to keep the message in sync with the schema. New columns, no message change.&lt;/li&gt;
&lt;li&gt;A pod that just booted does the same load as a pod that received a refresh kick. One code path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The downside: every refresh kick = one query per pod. If you have 100 pods and someone bulk-edits 1000 endpoints with 1000 publishes, that's 100,000 queries. We added a debounce (coalesce events within a 200 ms window). Onboarding a service still hammers the DB once, briefly, but it doesn't spiral.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching the lookup
&lt;/h2&gt;

&lt;p&gt;Even with a fast trie, re-walking the same path on every request is wasteful. We layer a TinyLFU cache (W-TinyLFU, via a Go port) on top of the resolver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;RouteCache&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;inner&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;tinylfu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RouteResult&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;RouteCache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RouteResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&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="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x00&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x00&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;path&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 key is &lt;code&gt;service_slug + NUL + method + NUL + path&lt;/code&gt;. The NUL byte is a delimiter — paths can't contain it, so we can't accidentally collide a slug with the start of another slug.&lt;/p&gt;

&lt;p&gt;The cache is bounded by entry count, not by RAM. Default: 10,000 routes with 100,000 frequency counters. TinyLFU keeps the &lt;em&gt;frequently used&lt;/em&gt; routes hot and evicts cold ones — better than LRU when traffic has a long tail of rarely-hit paths.&lt;/p&gt;

&lt;p&gt;Cache invalidation: on any trie reload (periodic or kick), the cache is &lt;em&gt;fully cleared&lt;/em&gt;. We considered partial invalidation. We chose not to — the cache fills back up in milliseconds because the same handful of paths drives 99% of the traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats per-route decorators
&lt;/h2&gt;

&lt;p&gt;In a Python or Node service you might write:&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="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;/users/&amp;lt;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;methods&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;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nd"&gt;@requires_permission&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:read&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;get_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three problems with that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Service-by-service drift.&lt;/strong&gt; Each service maintains its own decorators. Different services use different permission strings, different exception types, different log shapes. The contract is informal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fragile to refactor.&lt;/strong&gt; Move the route, lose the decorator. Now anyone can call it. We've seen this happen at zero, two, and twelve months in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit is impossible at the service level.&lt;/strong&gt; "What permission protects this URL?" requires reading every service's source.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By making endpoint metadata a &lt;em&gt;database row&lt;/em&gt;, we get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One contract, in one schema.&lt;/li&gt;
&lt;li&gt;Decoupled from code — no rebuild to add a route.&lt;/li&gt;
&lt;li&gt;Auditable: a single &lt;code&gt;SELECT&lt;/code&gt; answers "what does this URL require?"&lt;/li&gt;
&lt;li&gt;Reusable: the same metadata drives the gateway &lt;em&gt;and&lt;/em&gt; the admin UI that lets product managers tweak it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost is that you can't read the auth requirements next to the handler in the upstream service's code. We mitigate that with code generation: a CI job dumps &lt;code&gt;endpoint&lt;/code&gt; rows for each service into a &lt;code&gt;routes.yaml&lt;/code&gt; checked into the service's repo for reference. The DB stays the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Readiness depends on the trie
&lt;/h2&gt;

&lt;p&gt;There's a subtle interaction with Kubernetes probes worth calling out. Our &lt;code&gt;/readyz&lt;/code&gt; returns 503 if the trie isn't loaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;CheckTrieReadiness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tries&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;helpers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Trie&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;H&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="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusServiceUnavailable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"trie not loaded"&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="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusOK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pod that boots before Postgres is reachable will fail readiness, which keeps it out of the Service load balancer until the trie is loaded. A pod that &lt;em&gt;was&lt;/em&gt; serving traffic and then loses access to Postgres keeps its existing trie in memory and stays ready — refreshes fail loudly via Slack, but live traffic isn't disrupted.&lt;/p&gt;

&lt;p&gt;This split — "load fails block readiness, refresh failures don't" — is on purpose. A booting pod with no trie can't make decisions; pull it out. A serving pod with a stale trie can still make decisions correctly for endpoints that haven't changed; keep it in service while we fix Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-patterns we avoided
&lt;/h2&gt;

&lt;p&gt;Worth listing what we considered and rejected, because they're tempting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storing the endpoint type inside the JWT.&lt;/strong&gt; Tempting because then you don't need a trie. Wrong because a token outlives configuration changes — we'd cache stale auth requirements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single hard-coded list of public paths.&lt;/strong&gt; A previous iteration had a YAML file shipped with the gateway. Updating it required a deploy. The trie + DB replaced it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-tenant route metadata.&lt;/strong&gt; We talked about it. We rejected it: the same service exposes the same routes for every tenant. Tenant-specific differences belong in the access-level model (Chapter 6), not the route model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Letting the upstream service register its own routes via API at startup.&lt;/strong&gt; Looks elegant. Falls apart in chaos: a buggy service can register away its own auth.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Chapter 5 takes the same metadata-as-data philosophy and applies it to multi-tenancy. The trie tells us &lt;em&gt;what&lt;/em&gt; an endpoint requires; tenant resolution tells us &lt;em&gt;who&lt;/em&gt; it's for. We'll see how NGINX server blocks, headers, and tenant maps cooperate to never let a request through without a clear tenant identity.&lt;/p&gt;

</description>
      <category>go</category>
      <category>architecture</category>
      <category>auth</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Part 3 — Inside the Auth Service: From Token Validator to Policy Decision Point</title>
      <dc:creator>Akarshan Gandotra</dc:creator>
      <pubDate>Mon, 04 May 2026 18:40:48 +0000</pubDate>
      <link>https://dev.to/akarshan/inside-the-auth-service-from-token-validator-to-policy-decision-point-3kj8</link>
      <guid>https://dev.to/akarshan/inside-the-auth-service-from-token-validator-to-policy-decision-point-3kj8</guid>
      <description>&lt;p&gt;Most auth services start simple — verify the token, return 200 or 401. Then requirements accumulate. Tenant isolation. Service accounts. Token revocation. Access levels per endpoint. And suddenly what was a lightweight validator is carrying a lot of weight, without a clear structure to hold it.&lt;/p&gt;

&lt;p&gt;This post is about how we structured ours — the ideas that shaped it, and the ones we got wrong before landing here.&lt;/p&gt;




&lt;h2&gt;
  
  
  One job, lots of supporting infrastructure
&lt;/h2&gt;

&lt;p&gt;The Auth Service does exactly one thing from the outside: receive a subrequest from NGINX, inspect the headers, and return a decision. Under a millisecond, every time.&lt;/p&gt;

&lt;p&gt;But a single HTTP handler that does that reliably at scale has a lot underneath it — caching, revocation checks, routing logic, identity propagation. The structural challenge is keeping the handler &lt;em&gt;small&lt;/em&gt; while the infrastructure grows. We landed on a controller that reads like a flowchart:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Extract the request metadata (URI, method, tenant).&lt;/li&gt;
&lt;li&gt;Resolve the endpoint to find out what kind of auth it needs.&lt;/li&gt;
&lt;li&gt;Based on that: allow it openly, run authentication only, or run authentication &lt;em&gt;and&lt;/em&gt; authorization.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the whole thing. Everything else is a service the controller delegates to.&lt;/p&gt;




&lt;h2&gt;
  
  
  The insight that changed how we think about routing: endpoint classification is data, not code
&lt;/h2&gt;

&lt;p&gt;Early on, we made auth decisions in code. A route was open because someone wrote &lt;code&gt;if path == "/health" { return 200 }&lt;/code&gt;. Access control lived in conditionals scattered across handlers.&lt;/p&gt;

&lt;p&gt;This breaks the moment your product team adds a new endpoint, or you need to temporarily open a route for a partner integration, or you realize a route that was open should have been authenticated all along.&lt;/p&gt;

&lt;p&gt;We flipped it: every endpoint in the system has a classification stored in the database — &lt;code&gt;OPEN&lt;/code&gt;, &lt;code&gt;AUTHENTICATED&lt;/code&gt;, or &lt;code&gt;ACCESS_CONTROLLED&lt;/code&gt; — along with a permission list if it's access-controlled. The auth service resolves the incoming request to an endpoint record and reads that classification. The decision logic then becomes a simple switch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OPEN&lt;/strong&gt;: allow, log it, done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AUTHENTICATED&lt;/strong&gt;: run token validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ACCESS_CONTROLLED&lt;/strong&gt;: run token validation, then check permissions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The consequence is that we never recompile or redeploy the Auth Service to change how a route is protected. That's a database update. It also means non-engineers can reason about the access model without reading code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fexsssx65943hzt6aeqzs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fexsssx65943hzt6aeqzs.png" alt=" " width="800" height="902"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Naming every failure: the decision-reason contract
&lt;/h2&gt;

&lt;p&gt;The second structural idea that shaped everything else: every outcome has an explicit name.&lt;/p&gt;

&lt;p&gt;We maintain an enumerated list of decision reasons — constants like &lt;code&gt;MISSING_TOKEN&lt;/code&gt;, &lt;code&gt;TENANT_MISMATCH&lt;/code&gt;, &lt;code&gt;TOKEN_REVOKED&lt;/code&gt;, &lt;code&gt;SA_VERSION_MISMATCH&lt;/code&gt;, &lt;code&gt;OPEN_ENDPOINT&lt;/code&gt;, &lt;code&gt;ACCESS_LEVEL_MATCH&lt;/code&gt;. Every code path in the service must set one before returning. There's no exit that doesn't produce a named reason.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ReasonOpenEndpoint&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"OPEN_ENDPOINT"&lt;/span&gt;
    &lt;span class="n"&gt;ReasonMissingToken&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"MISSING_TOKEN"&lt;/span&gt;
    &lt;span class="n"&gt;ReasonTokenRevoked&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"TOKEN_REVOKED"&lt;/span&gt;
    &lt;span class="n"&gt;ReasonTenantMismatch&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"TENANT_MISMATCH"&lt;/span&gt;
    &lt;span class="n"&gt;ReasonSAVersionMismatch&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"SA_VERSION_MISMATCH"&lt;/span&gt;
    &lt;span class="n"&gt;ReasonTokenTypeMismatch&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"TOKEN_TYPE_MISMATCH"&lt;/span&gt;
    &lt;span class="c"&gt;// ... and so on&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sounds like a minor logging detail. It isn't.&lt;/p&gt;

&lt;p&gt;When a token fails, &lt;em&gt;why&lt;/em&gt; it fails tells a completely different story depending on the reason. &lt;code&gt;TOKEN_REVOKED&lt;/code&gt; means the user logged out or was disabled. &lt;code&gt;SA_VERSION_MISMATCH&lt;/code&gt; means a service account was rotated and the calling service hasn't caught up. &lt;code&gt;TOKEN_TYPE_MISMATCH&lt;/code&gt; means something is trying to authenticate with a refresh token where it should use an access token — usually a buggy SDK, occasionally something worth investigating.&lt;/p&gt;

&lt;p&gt;If all of these collapsed into a generic &lt;code&gt;401 Unauthorized&lt;/code&gt;, you'd lose all of that signal. Dashboards would be useless. On-call would be guessing.&lt;/p&gt;

&lt;p&gt;The list itself is a contract with the log pipeline. New reasons go through code review. Old reasons can't be deleted without checking dashboards and alerts first. It's one of the few places in the codebase where "this is more rigid than it needs to be" is actually correct.&lt;/p&gt;




&lt;h2&gt;
  
  
  One log line per request — and why that matters more than it sounds
&lt;/h2&gt;

&lt;p&gt;Our first approach was to emit log lines at each stage of the pipeline — one when we resolved the route, one when we validated the token, one when we made the authorization decision. We could stitch them together by request ID.&lt;/p&gt;

&lt;p&gt;We abandoned this. The stitching was always slightly wrong. Correlation IDs got dropped. Fields you needed were in a different log line than the one you found first. Debugging a production incident meant reconstructing a timeline from fragments.&lt;/p&gt;

&lt;p&gt;Now there's one structured log record per request. It's built up incrementally — every handler in the pipeline writes into the same struct. By the time the response goes out, the record has every field: URI, method, tenant, identity, cache hit status, decision reason, outcome. It emits once, at the end.&lt;/p&gt;

&lt;p&gt;The operational improvement was immediate. Grepping for a user's identity ID gives you a complete picture of every request they made — what was allowed, what was denied, and exactly why. No joining, no reconstruction.&lt;/p&gt;

&lt;p&gt;If you're designing an auth service, this is the first structural decision we'd recommend getting right. Everything else can be refactored. The logging model tends to calcify early.&lt;/p&gt;




&lt;h2&gt;
  
  
  How we handle JWT verification at scale
&lt;/h2&gt;

&lt;p&gt;Validating a JWT sounds cheap. For HS256 with a shared secret, it mostly is. For RS256 with asymmetric keys — which is what we use for user-facing tokens — the RSA verification step sits in the hundreds of microseconds. At meaningful request volume, that becomes a real CPU cost.&lt;/p&gt;

&lt;p&gt;Our solution is a cache in front of the decode step. The cache key is a hash of the raw token string (not the string itself — the hash is 8 bytes versus potentially hundreds, which adds up at scale). The TTL matches the token's expiry. When a token comes in that we've already verified recently, we skip the RSA verification entirely.&lt;/p&gt;

&lt;p&gt;A few things we were careful &lt;em&gt;not&lt;/em&gt; to cache:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revocation state.&lt;/strong&gt; Whether a token has been revoked can change at any moment, independent of the token's validity. We cache the decode result — the claims, the identity — but we always check revocation live. These are different questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The auth decision itself.&lt;/strong&gt; The decision depends on the endpoint, the tenant, and the required access level, none of which the token cache knows about. Caching decisions would mean a user who got their access level changed mid-session would still see stale decisions until cache expiry. Unacceptable.&lt;/p&gt;

&lt;p&gt;The principle here generalizes: cache the &lt;em&gt;facts&lt;/em&gt; (what the token says), not the &lt;em&gt;decisions&lt;/em&gt; (what we're going to do about it).&lt;/p&gt;




&lt;h2&gt;
  
  
  The boundary the Auth Service deliberately doesn't cross
&lt;/h2&gt;

&lt;p&gt;The clearest sign a service is well-designed is what it refuses to do.&lt;/p&gt;

&lt;p&gt;Our Auth Service handles coarse-grained access: does this identity have the level of access required to reach this endpoint category? That's it. It does not answer questions like "can this user delete this specific record?" or "does this account have permission to access this tenant's billing history?"&lt;/p&gt;

&lt;p&gt;Those are business policy questions. They belong in the services that own that data, where the full context exists.&lt;/p&gt;

&lt;p&gt;Every time we've been tempted to push business logic into the Auth Service — usually because it would be &lt;em&gt;convenient&lt;/em&gt;, or because a product requirement seemed auth-adjacent — we've regretted it. Business policy changes frequently. Auth infrastructure should be boring and stable. Keeping them separate means changes to one don't put the other at risk.&lt;/p&gt;

&lt;p&gt;The Auth Service also doesn't store sessions, doesn't issue tokens, and doesn't look up users. Tokens carry enough identity for upstream services to do that themselves. The Auth Service only validates.&lt;/p&gt;




&lt;h2&gt;
  
  
  The pattern underneath all of this
&lt;/h2&gt;

&lt;p&gt;Looking back, the decisions that held up over time share a common shape: make the implicit explicit.&lt;/p&gt;

&lt;p&gt;Endpoint classification pulled auth rules out of code and into data. Decision reasons named every outcome instead of letting them collapse into status codes. The single log line made the request lifecycle visible as a single artifact instead of scattered fragments. The cache/decision boundary separated "what the token says" from "what we're going to do about it."&lt;/p&gt;

&lt;p&gt;None of these are particularly novel ideas. But they compound. A service where every decision is named, every outcome is logged atomically, and every boundary is deliberate is a service you can actually operate.&lt;/p&gt;

&lt;p&gt;That's the goal.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: Chapter 4 — the path trie that resolves incoming URIs to endpoint records in O(path length), without a database call on the hot path.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>jwt</category>
      <category>auth</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
