<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Middleware |</title><link>https://kristianeschenburg.netlify.app/tag/middleware/</link><atom:link href="https://kristianeschenburg.netlify.app/tag/middleware/index.xml" rel="self" type="application/rss+xml"/><description>Middleware</description><generator>Source Themes Academic (https://sourcethemes.com/academic/)</generator><language>en-us</language><lastBuildDate>Tue, 27 Jan 2026 09:00:00 -0800</lastBuildDate><image><url>https://kristianeschenburg.netlify.app/img/Bayes.jpg</url><title>Middleware</title><link>https://kristianeschenburg.netlify.app/tag/middleware/</link></image><item><title>Correlation IDs and Request Lineage Across Services</title><link>https://kristianeschenburg.netlify.app/post/request-correlation-middleware/</link><pubDate>Tue, 27 Jan 2026 09:00:00 -0800</pubDate><guid>https://kristianeschenburg.netlify.app/post/request-correlation-middleware/</guid><description>&lt;p>We are a pretty small team, yet we still have a pretty large and expansive suite of services, dashboards, and backend data systems. When the number of services was small, auditting and logging was quite easy, but over the last few years, it&amp;rsquo;s become quite a chore. Imagine the following scenario:&lt;/p>
&lt;p>A scientist tells you the page on the cell culture analysis dashboard was slow this morning.&lt;/p>
&lt;p>If you can&amp;rsquo;t answer at least one of the following, you might find this post helpful:&lt;/p>
&lt;ol>
&lt;li>Which page?&lt;/li>
&lt;li>What do they mean &amp;ldquo;slow&amp;rdquo;?&lt;/li>
&lt;li>Which user?&lt;/li>
&lt;li>When?&lt;/li>
&lt;li>What endpoint and what parameters?&lt;/li>
&lt;/ol>
&lt;p>The dashboard they&amp;rsquo;re referring to called some backend API, that API called two more, and one of those queried the LIMS system directly. That&amp;rsquo;s multiple services and log streams, and I&amp;rsquo;m willing to bet that not one line in any of them tells you which entries belong to that user&amp;rsquo;s click.&lt;/p>
&lt;p>We face this issue in a variety of places in our data platform. I don&amp;rsquo;t mean logging in the general sense of &lt;code>import logging&lt;/code>, but the narrower scope of how a request identifies itself as it moves between all of our services, so that afterwards we can put the pieces back in order and say which step was slow, or which one returned the 403 error.&lt;/p>
&lt;p>I built something that I&amp;rsquo;ve been slowly deploying and incorporating into most of our services and dashboards. Three pieces do the work: a request-scoped context, middleware that fills it in, and a handful of headers forwarded on every outbound call. It pairs with
&lt;a href="https://kristianeschenburg.netlify.app/post/service-to-service-auth-cognito/">the previous post&lt;/a> on service-to-service auth, since knowing &lt;em>who&lt;/em> called and when is half the battle.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-wanted-every-line-to-carry">What I wanted every line to carry&lt;/h2>
&lt;p>My goal for this tooling was that any single &amp;ldquo;typical&amp;rdquo; log line, anywhere in the system, can answer which request, which user, which service, which endpoint, how long, and what happened. This ends up being a fixed set of fields stamped onto every record key:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>Example&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>request_id&lt;/code>&lt;/td>
&lt;td>&lt;code>req-474c2493-...&lt;/code>&lt;/td>
&lt;td>Ties every line from one request together, across services&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>session_id&lt;/code>&lt;/td>
&lt;td>&lt;code>ses-a55813da-...&lt;/code>&lt;/td>
&lt;td>Ties multiple requests from one browser session together&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_id&lt;/code>&lt;/td>
&lt;td>&lt;code>user@example.com&lt;/code>&lt;/td>
&lt;td>Who was actually doing this&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>service&lt;/code>&lt;/td>
&lt;td>&lt;code>lims-adapter&lt;/code>&lt;/td>
&lt;td>Which service emitted the line&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>requesting_service&lt;/code>&lt;/td>
&lt;td>&lt;code>dashboard&lt;/code>&lt;/td>
&lt;td>Which service called &lt;em>this&lt;/em> one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>method&lt;/code> / &lt;code>path&lt;/code>&lt;/td>
&lt;td>&lt;code>GET&lt;/code> &lt;code>/samples/S-123/molecule&lt;/code>&lt;/td>
&lt;td>Which endpoint&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>client&lt;/code>&lt;/td>
&lt;td>&lt;code>10.0.5.233&lt;/code>&lt;/td>
&lt;td>Source IP&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>status_code&lt;/code>&lt;/td>
&lt;td>&lt;code>403&lt;/code>&lt;/td>
&lt;td>What happened&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>duration_s&lt;/code>&lt;/td>
&lt;td>&lt;code>1.284&lt;/code>&lt;/td>
&lt;td>How long it took&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>event&lt;/code>&lt;/td>
&lt;td>&lt;code>request_end&lt;/code>&lt;/td>
&lt;td>Machine-readable label for the kind of line&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>From my perspective, &lt;code>request_id&lt;/code> and &lt;code>session_id&lt;/code> are the most helpful. A request ID covers one call to a service and everything it fans out into. A session ID covers everything one person did in one sitting. When someone says &amp;ldquo;it was slow this morning&amp;rdquo;, you find their session, then look at which requests inside it were slow.&lt;/p>
&lt;hr>
&lt;h2 id="how-the-context-travels">How the Context Travels&lt;/h2>
&lt;p>There are two mechanisms for how context travels within and between requests, one inside a process and one between processes.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
Browser(Browser)
Dash[Dashboard]
ApiA[Service A]
ApiB[Service B]
Lims[LIMS]
Logs[Log store]
Browser --&amp;gt;|&amp;#34;click&amp;#34;| Dash
Dash --&amp;gt;|&amp;#34;X-Request-ID, X-Session-ID, X-User-ID, X-Requesting-Service: dashboard&amp;#34;| ApiA
ApiA --&amp;gt;|&amp;#34;same IDs, X-Requesting-Service: service-a&amp;#34;| ApiB
ApiA --&amp;gt;|&amp;#34;same IDs&amp;#34;| Lims
Dash -.-&amp;gt;|&amp;#34;req-abc&amp;#34;| Logs
ApiA -.-&amp;gt;|&amp;#34;req-abc&amp;#34;| Logs
ApiB -.-&amp;gt;|&amp;#34;req-abc&amp;#34;| Logs
classDef store fill:#f3e8fd,stroke:#a142f4
class Logs store&lt;/code>&lt;/pre>&lt;p>Inside a process, the fields live in a
&lt;a href="https://docs.python.org/3/library/contextvars.html" target="_blank" rel="noopener">&lt;code>ContextVar&lt;/code>&lt;/a>, so they follow the request through &lt;code>await&lt;/code> chains and across threads without being passed as arguments. Between processes, they&amp;rsquo;re plain HTTP headers that outbound calls forward. &lt;code>X-Requesting-Service&lt;/code> is the one that changes at each hop, while the other three carry through unchanged. That is what lets one &lt;code>request_id&lt;/code> span the entire tree.&lt;/p>
&lt;hr>
&lt;h2 id="why-use-a-filter-not-a-loggeradapter">Why use a filter, not a LoggerAdapter&lt;/h2>
&lt;p>One way to attach context to log records is by using Python&amp;rsquo;s &lt;code>logging.LoggerAdapter&lt;/code>, but I think it&amp;rsquo;s the wrong tool for what I&amp;rsquo;m trying to do. An
&lt;a href="https://docs.python.org/3/library/logging.html#loggeradapter-objects" target="_blank" rel="noopener">adapter&lt;/a> applies only to log calls made &lt;em>through it&lt;/em>. Your own code gets the context, but the third-party library that raises the interesting exception does not, because it holds its own plain &lt;code>logging.getLogger(__name__)&lt;/code>.&lt;/p>
&lt;p>A &lt;code>logging.Filter&lt;/code> attached to the root handler sees every record in the process, irrespective of where it came from:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">_log_context&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ContextVar&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">dict&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ContextVar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;log_context&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">default&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">ContextFilter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Inject current context fields onto every LogRecord.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">filter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">record&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">LogRecord&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">bool&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">_global_fields&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">_log_context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">()}&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">items&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="nb">hasattr&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">record&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">setattr&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">record&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">True&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>hasattr&lt;/code> check means a field passed explicitly at the call site wins over the passive default context, so a caller can override &lt;code>user_id&lt;/code> for one line without unbinding it. And &lt;code>_global_fields&lt;/code> is a plain dict rather than a ContextVar, because ContextVar values aren&amp;rsquo;t inherited by new threads: static configuration like the service name has to live outside the context or it vanishes in a thread pool.&lt;/p>
&lt;p>Binding is a context manager that nests:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@contextmanager&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">fields&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">current&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_log_context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_log_context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">current&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">fields&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">yield&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">finally&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_log_context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">reset&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">rid&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">sid&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;request received&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># two fields&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">step&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;validate&amp;#34;&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;validating&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># three&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;continuing&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># back to two&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>token&lt;/code> mechanism restores the previous context exactly, including when the block exits by exception, which a naive save-and-restore gets wrong.&lt;/p>
&lt;p>There&amp;rsquo;s also a version without a scope boundary, for values that resolve partway through a request. Identity is the usual case: you don&amp;rsquo;t know the user until auth has run, but you want every line after that point to carry it.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">get_current_user&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">User&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">decode_token&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">update_log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">user&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># every later line carries it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">user&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="middleware">Middleware&lt;/h2>
&lt;p>The middleware operates once per request: accept or generate the IDs, resolve the user, bind everything, time the request, and echo the IDs back.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">request_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">headers&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;x-request-id&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;req-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">uuid&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">uuid4&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">session_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">headers&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;x-session-id&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;ses-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">uuid&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">uuid4&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Accept-or-generate is what enables cross-service correlation. The first service to see a request mints the ID, and every service after that inherits the minted ID from the header. Echoing the request and session IDs back in the response headers means a client, a browser console, or a support ticket can name the exact request, which turns &amp;ldquo;it was slow this morning&amp;rdquo; into a string I can now search for in CloudWatch. Identity resolves from whichever source is available, in priority order:&lt;/p>
&lt;ol>
&lt;li>&lt;code>x-amzn-oidc-data&lt;/code>, the ALB-injected Cognito JWT, decoded for the &lt;code>email&lt;/code> claim. This is a direct browser hit.&lt;/li>
&lt;li>&lt;code>X-User-ID&lt;/code>, forwarded by an upstream service that already decoded its own JWT.&lt;/li>
&lt;li>A dev stub, when &lt;code>APP_ENV&lt;/code> is &lt;code>dev&lt;/code>, &lt;code>local&lt;/code>, or unset.&lt;/li>
&lt;/ol>
&lt;p>The second tier carries a user&amp;rsquo;s identity into services that the user never talks to directly. Service B has no OIDC header, because its caller was service A, not a browser. It can still log which person&amp;rsquo;s click ultimately caused the call.&lt;/p>
&lt;h3 id="grading-by-status">Grading by status&lt;/h3>
&lt;p>&lt;code>request_end&lt;/code> is logged at &lt;code>ERROR&lt;/code> for 5xx, &lt;code>WARNING&lt;/code> for 4xx, and &lt;code>INFO&lt;/code> otherwise:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">log&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ERROR&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">status&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">500&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">WARNING&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">status&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">400&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">INFO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;request completed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">extra&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;event&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;request_end&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;status_code&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">status&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;duration_s&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">duration_s&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="403s-that-explain-nothing">403s that explain nothing&lt;/h2>
&lt;p>A route raises &lt;code>HTTPException(403, detail=&amp;quot;missing scope: service-b/write&amp;quot;)&lt;/code>. The client gets a useful error. The log gets a bare &lt;code>403&lt;/code> and nothing else. The reason is ordering: FastAPI&amp;rsquo;s &lt;code>ExceptionMiddleware&lt;/code> sits &lt;em>inside&lt;/em> the middleware, so by the time the middleware sees anything, the exception has already been converted into a response. The detail explaining the rejection went to the client and nowhere else. At that point, debugging a 403 means reproducing it, which is annoying and what this whole codebase is meant to fix.&lt;/p>
&lt;p>The fix is an exception handler that records the reason, registered alongside the middleware:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">http_exception_log_handler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">exc&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">log&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ERROR&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">exc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">status_code&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">500&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">WARNING&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;request rejected&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">extra&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;event&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;request_rejected&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;status_code&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">exc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">status_code&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;error_detail&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">_detail_text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">exc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">detail&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">_default_http_exception_handler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">exc&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_exception_handler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">StarletteHTTPException&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">http_exception_log_handler&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It runs inside the middleware&amp;rsquo;s context, so the request, session, and user fields are already bound. It delegates to Starlette&amp;rsquo;s default handler, so the response the client receives is unchanged. And because &lt;code>detail&lt;/code> is often a dict for structured errors, it gets JSON-serialized rather than &lt;code>str()&lt;/code>-ed, so it stays greppable instead of turning into Python repr with single quotes.&lt;/p>
&lt;hr>
&lt;h2 id="health-probe-overload">Health probe overload&lt;/h2>
&lt;p>A load balancer probes &lt;code>/health&lt;/code> every few seconds from every node, which means that those log lines can dominate log volume while reporting only that nothing happened, especially if they&amp;rsquo;re occuring frequently. I chose to suppress them, while still retaining useful information in the case that they fail:&lt;/p>
&lt;ol>
&lt;li>Skip the &lt;code>request_start&lt;/code>/&lt;code>request_end&lt;/code> pair in the middleware for &lt;em>passing&lt;/em> probes.&lt;/li>
&lt;li>Drop uvicorn&amp;rsquo;s own access-log line for the same requests, via a filter on the root handler.&lt;/li>
&lt;/ol>
&lt;p>Uvicorn logs independently of your middleware, so leaving that half in place means you&amp;rsquo;ve halved the noise and kept the volume. A probe returning 4xx or 5xx is always logged &amp;ndash; that&amp;rsquo;s the whole point of building this logging functionality. The correlation headers are still echoed on suppressed requests too, so if a probe starts failing and something retries, the retry is traceable.&lt;/p>
&lt;hr>
&lt;h2 id="auto-capturing-tracebacks">Auto-capturing tracebacks&lt;/h2>
&lt;p>A log call made from inside an &lt;code>except&lt;/code> block captures the active exception automatically, even when the caller writes &lt;code>log.warning(&amp;quot;call failed&amp;quot;)&lt;/code> with no &lt;code>exc_info=True&lt;/code>. I typically write &lt;code>logger.warning&lt;/code> in error handlers constantly, and almost never remember &lt;code>exc_info&lt;/code>. This results in a log full of &amp;ldquo;call failed&amp;rdquo; with no stacktrace. Capturing it by default means the traceback is there whether or not the author thought about it.&lt;/p>
&lt;p>The record then carries both locations, which are usually different:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-05-26T19:09:37.015695Z&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;level&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;WARNING&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;service&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;lims-adapter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;LIMS call failed: SQL query failed (upstream_status=405)&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;func&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;_lims_error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;filename&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;errors.py&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;lineno&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">47&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;request_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;req-474c2493-18ea-42a0-be0f-5ce4d096b14c&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;session_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ses-a55813da-3ab1-4471-9761-59d2ef988179&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;user_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user@example.com&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;path&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;/samples/S-20260129-269/molecule&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;exception&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Traceback (most recent call last):\n File \&amp;#34;.../molecule.py\&amp;#34;, line 10, in get_molecule\n ...\nLimsError: SQL query failed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>filename&lt;/code> and &lt;code>lineno&lt;/code> point at the error-handling code: the place someone decided to log. The traceback points somewhere else entirely, at the origin of the raise. That is where the bug lives. You want both, and they are rarely in the same file.&lt;/p>
&lt;hr>
&lt;h2 id="field-names-and-markers">Field names and markers&lt;/h2>
&lt;p>&lt;strong>Constants instead of string literals.&lt;/strong> A typo in &lt;code>fields.REQUEST_ID&lt;/code> is a &lt;code>NameError&lt;/code> at import. A typo in &lt;code>&amp;quot;request_id&amp;quot;&lt;/code> is a silent schema divergence that nobody notices until a dashboard query quietly returns fewer rows than it should.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">logging_lib&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">fields&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="n">fields&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">REQUEST_ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">rid&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">fields&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">USER_ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">uid&lt;/span>&lt;span class="p">}):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;handling request&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">extra&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="n">fields&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">EVENT&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;request_start&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Markers for categories.&lt;/strong> A top-level &lt;code>marker&lt;/code> field tags a record for dashboards and alerting: &lt;code>AUDIT&lt;/code> for user-initiated state changes, &lt;code>PERF&lt;/code> for timings, &lt;code>SECURITY&lt;/code> for auth and access control, &lt;code>SYSTEM&lt;/code> for lifecycle, &lt;code>PIPELINE&lt;/code> for job execution. It&amp;rsquo;s a small closed vocabulary, which is the point, and it makes queries trivial in whatever you&amp;rsquo;re storing logs in:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl"># CloudWatch Logs Insights
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">filter marker = &amp;#34;AUDIT&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"># Loki
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">{service=&amp;#34;lims-adapter&amp;#34;} | json | marker = &amp;#34;AUDIT&amp;#34;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="what-i-now-get">What I now get&lt;/h2>
&lt;p>With the IDs in place, three questions become queries rather than investigations.&lt;/p>
&lt;p>&lt;strong>Sequencing.&lt;/strong> Filter on one &lt;code>request_id&lt;/code>, sort by timestamp, and I have the ordered list of everything every service did for that one call, including the services the user never touched directly.&lt;/p>
&lt;p>&lt;strong>Lineage.&lt;/strong> &lt;code>requesting_service&lt;/code> on each hop gives the call graph as it actually ran&lt;/p>
&lt;p>&lt;strong>Latency attribution.&lt;/strong> Every service records &lt;code>duration_s&lt;/code> for the same &lt;code>request_id&lt;/code>. The dashboard reports 3.1 seconds, service A reports 2.9, service B reports 2.7, and the LIMS query reports 2.6. The slow thing is the LIMS query, and it&amp;rsquo;s established without adding a single timer.&lt;/p></description></item><item><title>Service-to-Service Auth with Cognito Scopes</title><link>https://kristianeschenburg.netlify.app/post/service-to-service-auth-cognito/</link><pubDate>Tue, 09 Dec 2025 09:00:00 -0800</pubDate><guid>https://kristianeschenburg.netlify.app/post/service-to-service-auth-cognito/</guid><description>&lt;p>I&amp;rsquo;m building tools that help scientists run their day-to-day work. I work in a heavily regulated environment, subject to both U.S. and European digital policy requirements. The scenario we face regularly is: two services talk to each other. Then three. A dashboard calls a backend API, that API calls another one, a nightly job calls all of them. Somewhow, we have to answer how one service proves to another that it is allowed to make the call.&lt;/p>
&lt;p>The first way I addressed this was to use Internal Bearer Tokens. Basically, you generate a long random string, store it in the AWS Secrets Manager, inject it into every service as an environment variable, have each service check inbound requests for it.&lt;/p>
&lt;p>I replaced that solution with Cognito&amp;rsquo;s OAuth2 client-credentials flow. Here each service carries its own identity and each endpoint declares the scope it requires. We didn&amp;rsquo;t need to define any new infrastructure for this, since Cognito was already authenticating browser users through the ALB, so the service-to-service (S2S) path ended up reusing the same user pool, the same JWKS endpoint, and the same validation code that was already there.&lt;/p>
&lt;hr>
&lt;h2 id="why-a-shared-bearer-token-was-not-viable">Why a shared bearer token was not viable&lt;/h2>
&lt;p>The bearer token approach was really easy, but it wasn&amp;rsquo;t a viable approach in the long run, for a few reasons:&lt;/p>
&lt;p>&lt;strong>No expiry.&lt;/strong> A static token has no TTL. If it leaks through a log line, a memory dump, or a compromised container, it stays valid until you manually rotate it and run a redeploy of every service holding it. There is no safe window during rotation where both the old and new values work, unless you write that special-casing yourself.&lt;/p>
&lt;p>&lt;strong>No identity.&lt;/strong> When service B receives the token, it can verify the value matches what it was given, but it can&amp;rsquo;t tell &lt;em>who&lt;/em> sent it. Every caller is indistinguishable, so you can only observe that the token was used.&lt;/p>
&lt;p>&lt;strong>No scope.&lt;/strong> The receiving service has no way to enforce what the caller may do. Either it accepts the token and grants full access, or it rejects it. There&amp;rsquo;s no way to express &amp;ldquo;service A may read but not write&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>Shared blast radius.&lt;/strong> The same secret works everywhere. One compromised service exposes the key to every other service, so a single breach leaves a huge hole.&lt;/p>
&lt;p>Comparing these two solutions against each other, we have:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Property&lt;/th>
&lt;th>Static bearer token&lt;/th>
&lt;th>Cognito client credentials&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Expiry&lt;/td>
&lt;td>Never&lt;/td>
&lt;td>~1 hour, auto-refreshed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Identity&lt;/td>
&lt;td>None (opaque string)&lt;/td>
&lt;td>Signed JWT with a &lt;code>client_id&lt;/code> claim&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Scope enforcement&lt;/td>
&lt;td>Impossible&lt;/td>
&lt;td>Per-endpoint, per-caller&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compromise blast radius&lt;/td>
&lt;td>All services, indefinitely&lt;/td>
&lt;td>One service, for TTL&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rotation&lt;/td>
&lt;td>Manual, coordinated redeploy&lt;/td>
&lt;td>Automatic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Auditability&lt;/td>
&lt;td>&amp;ldquo;the token was used&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;service A called endpoint X&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Being able to attribute traffic to a caller enhances the utility of logs, which is most of the subject of
&lt;a href="https://kristianeschenburg.netlify.app/post/request-correlation-middleware/">my next post&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="two-gates">Two gates&lt;/h2>
&lt;p>There are two authentication and authorization gates in this architecture:&lt;/p>
&lt;p>&lt;strong>Gate 1 is the ALB.&lt;/strong> Everything in our system is either accesible from outside our VPC via the ALB, or has ingress/egress restricted to within the VPC. The ALB decides only &lt;em>how a request gets in&lt;/em>: either this is an authenticated browser user who must complete an interactive Cognito login, or it&amp;rsquo;s a caller to hand straight to the service. The ALB knows nothing about your application&amp;rsquo;s permissions.&lt;/p>
&lt;p>&lt;strong>Gate 2 is the service.&lt;/strong> It validates &lt;em>identity&lt;/em> and enforces &lt;em>scope&lt;/em>. This is the real authorization check for S2S communication. It behaves identically no matter what language or host the caller runs on.&lt;/p>
&lt;p>An incoming request has to clear both. A browser user who hasn&amp;rsquo;t been authenticated gets a 302 status to the Cognito login page via Gate 1 and never reaches the dashboard or API. A service with a valid token but the wrong scope passes Gate 1 but receives a 403 at Gate 2.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">caller ──▶ [ Gate 1: ALB ] ──▶ [ Gate 2: service ] ──▶ your handler
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> browser or machine? who are you, and what may you do?
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ browser, │ no or invalid credential
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ not logged in │ valid token, wrong scope
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼ ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 302 → Cognito login 401 / 403&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="what-services-can-call-what">What services can call what&lt;/h2>
&lt;p>Below is a diagram of allowed communication. Solid arrows are requests, and each one is labeled with the scope it must present. Dashed arrows are token issuance.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
Human(Browser user)
Robot(Laptop / CI job)
ALB{ALB}
Dash[Dashboard]
SvcA[Service A]
SvcB[Service B]
SvcC[Service C]
Cognito(Cognito)
Human --&amp;gt;|&amp;#34;session cookie, no scope&amp;#34;| ALB
Robot --&amp;gt;|&amp;#34;Authorization: Bearer&amp;#34;| ALB
ALB --&amp;gt;|&amp;#34;x-amzn-oidc-identity&amp;#34;| Dash
ALB --&amp;gt;|&amp;#34;Bearer passthrough&amp;#34;| SvcA
Dash --&amp;gt;|&amp;#34;service-a/read&amp;#34;| SvcA
Dash --&amp;gt;|&amp;#34;service-b/read&amp;#34;| SvcB
SvcA --&amp;gt;|&amp;#34;service-b/read&amp;#34;| SvcB
SvcA --&amp;gt;|&amp;#34;service-c/write&amp;#34;| SvcC
Cognito -.-&amp;gt;|&amp;#34;token: service-a/read, service-b/read&amp;#34;| Dash
Cognito -.-&amp;gt;|&amp;#34;token: service-b/read, service-c/write&amp;#34;| SvcA
classDef human fill:#e8f0fe,stroke:#4285f4
classDef machine fill:#e6f4ea,stroke:#34a853
class Human human
class Robot machine&lt;/code>&lt;/pre>&lt;p>A browser user reaches the dashboard with no scope at all, because the ALB already authenticated them interactively and the dashboard is the thing they&amp;rsquo;re allowed to look at. The dashboard&amp;rsquo;s own token is &lt;em>narrower&lt;/em> than service A&amp;rsquo;s: it can read service A and service B, but nothing grants it write access to service C. If the dashboard is compromised, the write path isn&amp;rsquo;t reachable from there.&lt;/p>
&lt;p>The permissions are just a table, so adding a consumer means adding a row, not changing any service&amp;rsquo;s code:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>App client (the caller)&lt;/th>
&lt;th>Allowed scopes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>dashboard&lt;/code>&lt;/td>
&lt;td>&lt;code>service-a/read&lt;/code>, &lt;code>service-b/read&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>service-a&lt;/code>&lt;/td>
&lt;td>&lt;code>service-b/read&lt;/code>, &lt;code>service-c/write&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>ci-runner&lt;/code>&lt;/td>
&lt;td>&lt;code>service-a/read&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>developer&lt;/code>&lt;/td>
&lt;td>&lt;code>service-a/read&lt;/code>, &lt;code>service-b/read&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="cognito-resource-servers-custom-scopes-and-app-clients">Cognito resource servers, custom scopes, and app clients&lt;/h2>
&lt;h3 id="the-resource-server-defines-capabilities">The resource server defines capabilities&lt;/h3>
&lt;p>A &lt;strong>resource server&lt;/strong> represents a service that exposes capabilities. It has two
name-like fields that do very different jobs:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># terraform code
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_cognito_resource_server&amp;#34; &amp;#34;service_b&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> user_pool_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">cognito_user_pool_id&lt;/span>&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # The scope prefix. Opaque string, conventionally a URL. Immutable in practice.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> identifier&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;https://api.example.com/service-b&amp;#34;&lt;/span>&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # Console display name only. Nothing references it.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;service-b&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">scope&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;read&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Read access to service-b&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">scope&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;write&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Write access to service-b&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">scope&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;admin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Administrative access to service-b&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>identifier&lt;/code> field becomes the prefix of every scope the
resource server defines, so the three scopes above are really:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">https://api.example.com/service-b/read
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">https://api.example.com/service-b/write
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">https://api.example.com/service-b/admin&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That full string is what a client requests, what appears in the JWT&amp;rsquo;s &lt;code>scope&lt;/code>
claim, and what a service compares against. &lt;code>name&lt;/code> is a display label in the
console and is referenced by nothing.&lt;/p>
&lt;h3 id="only-custom-scopes-work-for-machine-to-machine">Only custom scopes work for machine-to-machine&lt;/h3>
&lt;p>The client-credentials flow works &lt;strong>only&lt;/strong> with custom scopes from a resource server. I defined these scope manually in my FastAPI applications, and decorated the relevant endpoints with their respective scopes. The built-in OpenID scopes (&lt;code>openid&lt;/code>, &lt;code>email&lt;/code>, &lt;code>profile&lt;/code>, &lt;code>aws.cognito.signin.user.admin&lt;/code>)
are for user-facing flows, and asking for one with &lt;code>grant_type=client_credentials&lt;/code>
fails. Every machine caller needs at least one resource server to exist,
even if the service it calls has exactly one capability.&lt;/p>
&lt;p>The app client also needs a secret and the flow explicitly enabled. A client
without &lt;code>generate_secret&lt;/code> cannot do client credentials at all, and the user pool
needs a domain configured, since the token endpoint lives at
&lt;code>https://&amp;lt;domain&amp;gt;.auth.&amp;lt;region&amp;gt;.amazoncognito.com/oauth2/token&lt;/code>.&lt;/p>
&lt;h3 id="the-app-client-selects-a-subset">The app client selects a subset&lt;/h3>
&lt;p>An &lt;strong>app client&lt;/strong> represents a caller of a service. One per calling service, with
&lt;code>allowed_oauth_scopes&lt;/code> listing every downstream capability it may request:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_cognito_user_pool_client&amp;#34; &amp;#34;service_a_m2m&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;service-a-m2m&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> user_pool_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">cognito_user_pool_id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> generate_secret&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kt">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> allowed_oauth_flows_user_pool_client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kt">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> allowed_oauth_flows&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;client_credentials&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # Full scope strings, exactly as composed above.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> allowed_oauth_scopes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;https://api.example.com/service-b/read&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;https://api.example.com/service-c/write&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # Cognito validates that each scope exists, so the resource servers
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # must be created first. Terraform will not infer this ordering.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> depends_on&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">aws_cognito_resource_server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">service_b&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">aws_cognito_resource_server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">service_c&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Service A is granted &lt;code>read&lt;/code> on service B, and &lt;code>write&lt;/code> on service C. It cannot write to service B even though that scope exists, because its app client never lists it as a viable permissions. Cognito rejects an app client referencing a scope that doesn&amp;rsquo;t exist yet, and Terraform sees no dependency between the two resources because the scope is a hand-written string rather than a reference to the resource server&amp;rsquo;s attributes.&lt;/p>
&lt;p>&lt;strong>Scopes model capabilities, not consumers.&lt;/strong> Service B declares that reading,
writing, and administering are things that can be done to it (this is defined both in the FastAPI code via decorated endpoints, and in the Cognito resource server). But Service B doesn&amp;rsquo;t say anything about who can take those actions. Consumers get assigned action permissions through their app client&amp;rsquo;s allowed-scopes list.&lt;/p>
&lt;p>That separation lets you onboard a caller without touching the
service being called. A new consumer is one app client and one secret. Service B
does not redeploy, does not learn the new caller&amp;rsquo;s name, and does not grow a
config entry. Its code already says &lt;code>require_scope(&amp;quot;read&amp;quot;)&lt;/code> and will keep saying
that no matter how many services eventually call it.&lt;/p>
&lt;h3 id="two-possible-gotchas">Two possible gotchas&lt;/h3>
&lt;p>&lt;strong>An omitted scope parameter is not the same as no scopes.&lt;/strong> If a token request
leaves &lt;code>scope&lt;/code> off entirely, Cognito issues a token carrying &lt;em>every&lt;/em> scope the app
client is allowed. This means that every downstream call then presents maximum privilege. Always request scopes explicitly, one per downstream call. This is also why caching tokens per scope rather than per client matters.&lt;/p>
&lt;p>&lt;strong>Removing a scope is harder than adding one.&lt;/strong> Cognito will not let you delete a
scope from a resource server while an app client still lists it in
&lt;code>allowed_oauth_scopes&lt;/code>. The order is: remove it from every app client, apply, then
remove it from the resource server. In Terraform that&amp;rsquo;s two applies, and doing it
in one produces an error that names the resource server rather than the app client that&amp;rsquo;s actually the problem.&lt;/p>
&lt;h2 id="validating-a-token">Validating a token&lt;/h2>
&lt;p>The receiving side has to check four things:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>signature&lt;/strong>, against Cognito&amp;rsquo;s public JWKS for the pool.&lt;/li>
&lt;li>The &lt;strong>issuer&lt;/strong>, so a token from some other pool isn&amp;rsquo;t accepted.&lt;/li>
&lt;li>&lt;code>token_use == &amp;quot;access&amp;quot;&lt;/code>, so an ID token can&amp;rsquo;t be substituted for an access token.&lt;/li>
&lt;li>The &lt;strong>scope claim&lt;/strong> contains the specific scope the endpoint requires.&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">_decode_token&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">token&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">signing_key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_jwk_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_signing_key_from_jwt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">claims&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">jwt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">signing_key&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">algorithms&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;RS256&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">issuer&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Client-credentials tokens carry `client_id` instead of `aud`,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># so skip audience verification and check token_use instead.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">options&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;verify_aud&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">claims&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;token_use&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;access&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">401&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;wrong token type&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">claims&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Client-credentials tokens have no &lt;code>aud&lt;/code> claim. Instead, they carry &lt;code>client_id&lt;/code> , so leaving audience verification on rejects every valid S2S token with a confusing error. Turning it off here is valid, but only because &lt;code>token_use&lt;/code> and the issuer are being checked instead.&lt;/p>
&lt;p>Scope enforcement then becomes a dependency on the router:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">full_scope&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">resource_server_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">/&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_scope&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">full_scope&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">claims&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;scope&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">403&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;token missing required scope: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">full_scope&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">app&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">FastAPI&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependencies&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Depends&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">auth&lt;/span>&lt;span class="p">)])&lt;/span> &lt;span class="c1"># authenticated everywhere&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">router&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">APIRouter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependencies&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Depends&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">auth&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">require_scope&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;read&amp;#34;&lt;/span>&lt;span class="p">))])&lt;/span> &lt;span class="c1"># plus a scope&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>There is also a design decision buried here. Browser requests arriving with ALB OIDC headers pass the scope check without a scope, since the ALB already authenticated the human interactively and browser sessions have no scopes to check.&lt;/p>
&lt;hr>
&lt;h2 id="fetching-a-token">Fetching a token&lt;/h2>
&lt;p>The outbound half fetches tokens from Cognito and caches them until they expire.&lt;/p>
&lt;p>&lt;strong>Cache per scope, not per client.&lt;/strong> A service calling three downstream APIs holds three tokens, each carrying only the access it needs for that call.&lt;/p>
&lt;p>&lt;strong>Guard the refresh with a lock, and re-check inside it.&lt;/strong> Under concurrent load, several coroutines will notice the expired token at the same moment and all stampede Cognito&amp;rsquo;s token endpoint:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">get_token&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">scope&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cached&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_tokens&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">cached&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="n">cached&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">expires_at&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">cached&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">access_token&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">scope&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_locks&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_locks&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">asyncio&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Lock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">with&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_locks&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Re-check inside the lock: another coroutine may have fetched already.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cached&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_tokens&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">cached&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="n">cached&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">expires_at&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">cached&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">access_token&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_tokens&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_fetch_new_token&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_tokens&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">access_token&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Expiry is stored with a 60 second skew subtracted, so a token is treated as expired slightly before Cognito thinks so. Without that, a token that passes the check and then spends 400ms in flight can arrive already invalid.&lt;/p>
&lt;hr>
&lt;h2 id="required-alb-rules">Required ALB rules&lt;/h2>
&lt;p>If your ALB has a Cognito authentication action on its listener rule, it applies to &lt;em>every&lt;/em> request on that rule. A service presenting a valid Bearer token gets a 302 redirect to the Cognito login page. It never reaches your service, so all that careful token validation never runs.&lt;/p>
&lt;p>The listener needs to rules, defined in order:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Higher priority:&lt;/strong> match requests carrying an &lt;code>Authorization: Bearer&lt;/code> header and &lt;code>forward&lt;/code> them straight to the EC2 target group, with no authenticate action.&lt;/li>
&lt;li>&lt;strong>Lower priority:&lt;/strong> everything else gets &lt;code>authenticate-cognito&lt;/code> and the normal browser login.&lt;/li>
&lt;/ol>
&lt;h3 id="gotcha-source-ip-bypass-breaks-browser-login">Gotcha! Source-IP bypass breaks browser login&lt;/h3>
&lt;p>With respect to Gate 1, you could also consider matching on &lt;strong>source IP&lt;/strong> instead, forwarding anything from an allowlisted CIDR. This fixes Gate 1, and Gate 2 is unchanged either way.&lt;/p>
&lt;p>However, a source-IP matching rule forwards &lt;em>without&lt;/em> running the authenticate action. So once you allowlist the office or VPN range, a person opening a dashboard in a browser from that network skips the login entirely and arrives with no session and no token. This is DEFINITELY NOT what you want, because we want all requests to be authenticated. This is specific to browser users, and S2S communication still works.&lt;/p>
&lt;p>Also, allowlisting the &lt;strong>VPC&amp;rsquo;s own CIDR&lt;/strong> admits in-VPC services only. A public ALB sees a laptop&amp;rsquo;s corporate or VPN &lt;em>egress&lt;/em> IP. That address is nowhere near the VPC range. So a VPC-CIDR rule never admits laptops, even on VPN. If in-VPC calls work and laptops get redirected to a login page, this is why.&lt;/p>
&lt;h3 id="gotcha-header-bypass-can-worsen-an-authentication-bug">Gotcha! Header bypass can worsen an authentication bug&lt;/h3>
&lt;p>We trust browser requests based on the presence of the &lt;code>x-amzn-oidc-identity&lt;/code> header, and that the browser path skips scope checks. That&amp;rsquo;s normally safe, because the ALB&amp;rsquo;s Cognito action &lt;em>overwrites&lt;/em> that header on every request it processes, so a client can&amp;rsquo;t forge it.&lt;/p>
&lt;p>A bypassed request doesn&amp;rsquo;t run that action, so with a header-match rule, anyone on the internet can send:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">Authorization: Bearer anything
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">x-amzn-oidc-identity: someone@example.com&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The first header triggers the bypass. The second makes your service treat the request as an authenticated browser user, which skips the scope check entirely. The &lt;code>Bearer&lt;/code> value is never validated, because the ALB-header path returns before the token path is reached.&lt;/p>
&lt;p>The mitigations, either of which closes it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Strip inbound &lt;code>x-amzn-oidc-*&lt;/code> headers on the bypass path&lt;/strong>, so only the ALB can ever set them.&lt;/li>
&lt;li>&lt;strong>Dont trusting identity by presence of the header alone.&lt;/strong> Verify the signed &lt;code>x-amzn-oidc-data&lt;/code> JWT rather than reading &lt;code>x-amzn-oidc-identity&lt;/code> as a plain string.&lt;/li>
&lt;/ul>
&lt;p>The second is the better fix because it removes a whole class of bugs instance of one instance of it.&lt;/p>
&lt;hr>
&lt;h2 id="calling-it-from-anywhere">Calling it from anywhere&lt;/h2>
&lt;p>Something that confused me initially was that &lt;code>curl&lt;/code> requests from the CLI would fail. But this was because we weren&amp;rsquo;t passing in any form of authentication into the request, which made local testing or scenarios like calling a service directly from an EC2 instance, fail. I enabled this functionality with two HTTP steps that are pretty much identical in Python, Java, or a shell script:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">TOKEN&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>curl -s -X POST &lt;span class="s2">&amp;#34;https://&amp;lt;domain&amp;gt;.auth.&amp;lt;region&amp;gt;.amazoncognito.com/oauth2/token&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -u &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$CLIENT_ID&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="nv">$CLIENT_SECRET&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="nv">grant_type&lt;/span>&lt;span class="o">=&lt;/span>client_credentials &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;scope=https://api.example.com/service-a/read&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> jq -r .access_token&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl &lt;span class="s2">&amp;#34;https://api.example.com/service-a/things&amp;#34;&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="nv">$TOKEN&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A Python client is a convenience wrapper around these steps &amp;ndash; it&amp;rsquo;s not the ONLY way to run this authorization flow. Anything that can make an HTTPS request can participate. But we might want to alleviate this by building in a seperate app client for developers and for automation purposes.&lt;/p>
&lt;hr>
&lt;h2 id="wrapping-up">Wrapping up&lt;/h2>
&lt;p>This was a pretty approachable problem, especially since we already had the infrastructure in place. We were already using Cognito to authenticate browser users through the ALB, and the client-credentials flow runs against the same user pool, the same JWKS endpoint, and the same validation code. We just needed one more OAuth flow on the system already in place.&lt;/p></description></item></channel></rss>