<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Web API Security Case Studies · Remote Since Forever</title>
  <subtitle>Case studies of Node.js Express API security findings — CORS allowlist, JWT audience map, X-Forwarded-For rate-limit, S3 IAM, CloudFront header leak.</subtitle>
  <link href="https://blog.clearview.team/tags/web-api-security/feed.xml" rel="self" type="application/atom+xml" />
  <link href="https://blog.clearview.team/tags/web-api-security/" />
  <updated>2026-08-20T17:39:48+02:00</updated>
  <id>https://blog.clearview.team/tags/web-api-security/feed.xml</id>
  <author>
    <name>Clearview Team</name>
  </author>
  <entry>
    <title>How a CloudFront Custom Error Page Leaked JWTs to S3: A Case Study</title>
    <link href="https://blog.clearview.team/2026/cloudfront-error-page-jwt-leak-case-study/" />
    <id>https://blog.clearview.team/2026/cloudfront-error-page-jwt-leak-case-study/</id>
    <published>2026-08-13T11:00:00+02:00</published>
    <updated>2026-08-13T11:00:00+02:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>A pen-test curl found CloudFront forwarding `Authorization` headers to the S3 error-page bucket, where S3 reflected every JWT back in its XML error response. Two lines of Terraform closed it across sixteen distributions.</summary>
    <content type="html">&lt;p&gt;A client was preparing to put an API behind CloudFront in production. During the final penetration test, one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; request showed that every distribution with custom error pages was reflecting users&apos; JWT tokens in S3 XML error responses.&lt;/p&gt;

&lt;p&gt;Sixteen distributions shared the same copy-pasted Terraform block.&lt;/p&gt;

&lt;p&gt;CloudFront is AWS&apos;s content delivery network (CDN). A JSON Web Token (JWT) is the signed session string the API gives a user after login. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; is the command-line tool we use to send HTTP requests by hand. Three terms are enough to follow the finding.&lt;/p&gt;

&lt;p&gt;CloudFront was handling TLS at the edge, HTTP/2 and HTTP/3 connections, DDoS protection, and branded error pages from a separate S3 bucket. The staging setup had been running for weeks. The distributions were stable, the error pages rendered correctly, and the TLS grades looked good.&lt;/p&gt;

&lt;h2 id=&quot;the-one-line-proof&quot;&gt;The One-Line Proof&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;curl https://your-api.example.com/custom_error_pages/502.html &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.YOUR_ACTUAL_TOKEN&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;If your response looks like this, you&apos;re vulnerable:&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;cp&quot;&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;Error&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;Code&amp;gt;&lt;/span&gt;InvalidArgument&lt;span class=&quot;nt&quot;&gt;&amp;lt;/Code&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;Message&amp;gt;&lt;/span&gt;Unsupported Authorization Type&lt;span class=&quot;nt&quot;&gt;&amp;lt;/Message&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;ArgumentName&amp;gt;&lt;/span&gt;Authorization&lt;span class=&quot;nt&quot;&gt;&amp;lt;/ArgumentName&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;ArgumentValue&amp;gt;&lt;/span&gt;Bearer eyJhbGciOiJIUzI1NiJ9.YOUR_ACTUAL_TOKEN&lt;span class=&quot;nt&quot;&gt;&amp;lt;/ArgumentValue&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/Error&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Your full JWT (user ID, email, role, expiry, everything in the payload) reflected back in an S3 XML error response. The path exists on practically every CloudFront distribution with custom error pages. Any GET request with an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header triggers it, no authentication and no origin failure required.&lt;/p&gt;

&lt;p&gt;S3 reflects any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; scheme, not just Bearer. Basic auth credentials (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Basic dXNlcjpwYXNzd29yZA==&lt;/code&gt;), API tokens (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Token API_TOKEN_VALUE&lt;/code&gt;), anything non-AWS gets dumped into the XML. If your API uses any of these, the same leak applies.&lt;/p&gt;

&lt;p&gt;Cookies, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;X-API-Key&lt;/code&gt;, and custom headers are forwarded to S3 too but not reflected in the XML. S3 only complains about the one header it tries to parse.&lt;/p&gt;

&lt;h2 id=&quot;why-did-cloudfront-forward-the-authorization-header-to-s3&quot;&gt;Why did CloudFront forward the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header to S3?&lt;/h2&gt;

&lt;p&gt;A CloudFront distribution sits between users and one or more &lt;strong&gt;origins&lt;/strong&gt; (the actual servers that hold your content). Each origin gets a &lt;strong&gt;behavior&lt;/strong&gt; that tells CloudFront which requests to route there and what to forward along.&lt;/p&gt;

&lt;p&gt;In this pattern, there are two origins behind one distribution:&lt;/p&gt;

&lt;figure class=&quot;post-figure--wide&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/cloudfront-error-page-jwt-leak-case-study/topology.svg&quot; alt=&quot;CloudFront distribution topology: the default behavior routes /api/* to the ALB with all viewer headers (correct), while the ordered behavior routes /custom_error_pages/* to S3 with the same headers (misconfigured), causing the JWT to leak.&quot; loading=&quot;lazy&quot; /&gt;
  &lt;figcaption&gt;The API behavior needs viewer headers. The S3 error-page behavior does not.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The API origin is your application: an Application Load Balancer (ALB) in front of Elastic Container Service (ECS) containers, a Lambda function, or an EC2 instance. It needs the viewer&apos;s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header to authenticate requests, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt; to parse bodies, and cookies for sessions. Forwarding everything to this origin is correct.&lt;/p&gt;

&lt;p&gt;The error page origin is an S3 bucket containing three static HTML files: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;502.html&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;503.html&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;404.html&lt;/code&gt;. It serves the same branded &quot;We&apos;ll be right back&quot; page to every user regardless of who they are.&lt;/p&gt;

&lt;p&gt;It needs nothing from the viewer: not their identity, not their cookies, not their query strings. It just needs CloudFront to ask for a file by path, and it returns the HTML.&lt;/p&gt;

&lt;p&gt;The problem is that both origins were given the same origin request policy: the one designed for the API. So when CloudFront routes a request to the S3 error page bucket, it sends along everything the viewer included, including &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization: Bearer &amp;lt;JWT&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;S3 is not your API. It doesn&apos;t understand Bearer tokens. It tries to interpret the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header as AWS Signature Version 4, fails, and returns an XML error that includes the header value it couldn&apos;t parse. Your JWT is now in the response body.&lt;/p&gt;

&lt;p&gt;The setup looks like this in Terraform (CloudFormation and the console follow the same pattern):&lt;/p&gt;

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# Origin 1: Your API (ALB, ECS, Lambda, etc.)&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;origin&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;domain_name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_lb&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;dns_name&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;origin_id&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;api&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Origin 2: S3 bucket with static error pages&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;origin&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;domain_name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_s3_bucket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;error_pages&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;website_endpoint&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;origin_id&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;error-pages&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# When the API returns 502, serve the S3 error page instead&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;custom_error_response&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;error_code&lt;/span&gt;         &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;502&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;response_page_path&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;/custom_error_pages/502.html&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Behavior for the API (default): forwards all viewer headers&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;default_cache_behavior&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;target_origin_id&lt;/span&gt;         &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;api&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;origin_request_policy_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_cloudfront_origin_request_policy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;AllViewerExceptHostHeader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Behavior for the error pages: ALSO forwards all viewer headers&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;ordered_cache_behavior&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;path_pattern&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;/custom_error_pages/*&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;target_origin_id&lt;/span&gt;         &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;error-pages&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;origin_request_policy_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_cloudfront_origin_request_policy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;AllViewerExceptHostHeader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;# ↑ THIS IS THE PROBLEM&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AllViewerExceptHostHeader&lt;/code&gt; policy tells CloudFront: &quot;forward every header the viewer sent (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cookie&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;X-Custom-Whatever&lt;/code&gt;) to the origin, except &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Host&lt;/code&gt;.&quot; That&apos;s correct for the API behavior, where your backend needs those headers. It&apos;s catastrophically wrong for the error page behavior, where the origin is an S3 bucket serving static HTML.&lt;/p&gt;

&lt;aside class=&quot;callout&quot;&gt;
  &lt;p&gt;&lt;strong&gt;A note on policy names.&lt;/strong&gt; We call out &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AllViewerExceptHostHeader&lt;/code&gt; because that&apos;s what was on the error page behaviors we audited. But AWS&apos;s managed policy &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Managed-AllViewer&lt;/code&gt;&lt;/strong&gt; does the same thing. Any policy with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;header_behavior = &quot;allViewer&quot;&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;header_behavior = &quot;allExcept&quot;&lt;/code&gt; that doesn&apos;t explicitly exclude &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; will trigger the same leak.&lt;/p&gt;

  &lt;p&gt;In CloudFormation, look for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OriginRequestPolicyId&lt;/code&gt; on your error page &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CacheBehavior&lt;/code&gt;. In CDK, check &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;originRequestPolicy&lt;/code&gt; on your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BehaviorOptions&lt;/code&gt;. The policy names and IDs are the same; only the syntax differs.&lt;/p&gt;
&lt;/aside&gt;

&lt;h2 id=&quot;three-ways-the-token-leaks&quot;&gt;Three ways the token leaks&lt;/h2&gt;

&lt;p&gt;The error page path isn&apos;t linked anywhere. No browser navigates to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/custom_error_pages/502.html&lt;/code&gt; during normal use. Three practical routes still reach it.&lt;/p&gt;

&lt;p&gt;The path is trivially discoverable by scanners. It&apos;s in CloudFront&apos;s own documentation examples, and tools like ffuf, feroxbuster, and nuclei include &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/error/&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/custom_error_pages/&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/502.html&lt;/code&gt; in their default wordlists. A scanner that hits the path and sees &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;Code&amp;gt;InvalidArgument&amp;lt;/Code&amp;gt;&lt;/code&gt; with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;ArgumentName&amp;gt;Authorization&amp;lt;/ArgumentName&amp;gt;&lt;/code&gt; in the XML response can identify the misconfiguration from the shape alone. Our pentest tooling found this one that way.&lt;/p&gt;

&lt;p&gt;A production outage can leak the token without any scanner. When the origin returns 502/503, CloudFront intercepts the error and fetches the S3 error page with the original request&apos;s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header attached. The user didn&apos;t navigate to the error page; CloudFront sent them there. Their token leaks through a normal deployment, container restart, or scaling event, visible in the response body to any browser extension, corporate proxy, or compromised CDN edge watching that window.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Path&lt;/th&gt;
      &lt;th&gt;Trigger&lt;/th&gt;
      &lt;th&gt;Result&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Direct access&lt;/td&gt;
      &lt;td&gt;A GET request with an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header reaches &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/custom_error_pages/502.html&lt;/code&gt;.&lt;/td&gt;
      &lt;td&gt;S3 reflects the header in XML. No origin failure is needed.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Origin failure&lt;/td&gt;
      &lt;td&gt;An API request carries a JWT, then the origin returns 502 or 503.&lt;/td&gt;
      &lt;td&gt;CloudFront fetches the S3 error page with the original headers, and the token comes back to the user.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Plain HTTP&lt;/td&gt;
      &lt;td&gt;The error-page behavior uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;viewer_protocol_policy = &quot;allow-all&quot;&lt;/code&gt;.&lt;/td&gt;
      &lt;td&gt;A network attacker can read the token without breaking its cryptography.&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Most of the distributions we audited had the plain-HTTP setting. The API behavior used &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;redirect-to-https&lt;/code&gt;; the error-page behavior was a separate block with separate settings.&lt;/p&gt;

&lt;h2 id=&quot;the-terraform-template-problem&quot;&gt;The Terraform Template Problem&lt;/h2&gt;

&lt;p&gt;We found this in a client&apos;s infrastructure codebase. We audited every CloudFront distribution across their staging and production accounts. The same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ordered_cache_behavior&lt;/code&gt; block for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/custom_error_pages/*&lt;/code&gt; showed up on distribution after distribution, all pointing to the same S3 error pages bucket, all using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AllViewerExceptHostHeader&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The block was written once for the API distribution, where it was correct. Then it was copied to every other distribution (SPAs, admin dashboards, static sites, upload CDNs) because it &quot;worked&quot; and nobody questioned whether a static error page bucket needed to receive the viewer&apos;s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;Terraform&apos;s copy-paste turned one mistake into a pattern across the whole account.&lt;/p&gt;

&lt;p&gt;A handful of the affected distributions served authenticated traffic (APIs and legacy apps). Those were fully exploitable: every authenticated request that hit a 502 leaked the caller&apos;s JWT. The rest served static frontends that don&apos;t normally receive &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; headers, but the direct-access path still worked on all of them.&lt;/p&gt;

&lt;h2 id=&quot;the-chain-that-made-it-critical&quot;&gt;The Chain That Made It Critical&lt;/h2&gt;

&lt;p&gt;The JWT leak alone is a HIGH: you can capture a token and replay it for the remaining access-token lifetime. But during this engagement, we found it chained with something else that pushed it to CRITICAL.&lt;/p&gt;

&lt;p&gt;The API signs session tokens with a JWT secret. On staging, that secret was hardcoded as a trivially guessable value in the ECS task definition&apos;s plaintext environment block. Not in Secrets Manager, not pulled from SSM Parameter Store, just a short, dictionary-word string sitting in Terraform.&lt;/p&gt;

&lt;p&gt;We confirmed the secret remotely without any internal access. The API returns different error messages depending on whether a token&apos;s signature verifies:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Token signed with the guessable secret (correct):&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-X&lt;/span&gt; POST https://api.staging.example.com/api/session/token &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Cookie: session=&amp;lt;forged-token-signed-with-guessable-secret&amp;gt;&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# → &quot;Session has been revoked&quot; (signature PASSED, DB hash lookup failed)&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# Token signed with wrong secret:&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-X&lt;/span&gt; POST https://api.staging.example.com/api/session/token &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Cookie: session=&amp;lt;token-with-wrong-signature&amp;gt;&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# → &quot;Invalid session&quot; (signature FAILED)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That error difference acts as an oracle. &quot;Session has been revoked&quot; means the JWT signature verified successfully; the server moved past cryptographic verification to the database lookup phase. &quot;Invalid session&quot; means the signature check failed. The two code paths are distinguishable remotely.&lt;/p&gt;

&lt;h3 id=&quot;we-proved-the-full-chain-end-to-end&quot;&gt;We proved the full chain end-to-end&lt;/h3&gt;

&lt;p&gt;We registered a test account and signed in. We captured the session token, leaked it through the error page, and validated it offline. Then we replayed it and had the account. All with curl.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Sign in.&lt;/strong&gt; We registered a test account and signed in. The server set an httpOnly session cookie. It was a long-lived JWT signed with the guessable secret.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Leak the token.&lt;/strong&gt; We sent a GET request to the error page path. The session token was in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header. The S3 XML error returned it verbatim. Byte for byte, it matched the original.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Validate offline.&lt;/strong&gt; We computed the HMAC-SHA256 signature using the guessable secret. It matched the token&apos;s signature exactly. No server interaction needed. The attacker now knows the token is real, who it belongs to, and when it expires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Replay.&lt;/strong&gt; We sent the leaked session token to the token endpoint. The server verified the JWT signature (passed) and looked up the token hash (found a real active session). It minted a fresh access token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Account takeover.&lt;/strong&gt; We used the fresh access token to call the user-profile endpoint. The server returned the full profile: ID, email, name, role, all PII fields. Full account access with no password. All from a token that was never meant to leave the httpOnly cookie jar.&lt;/p&gt;

&lt;figure class=&quot;post-figure--wide&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/cloudfront-error-page-jwt-leak-case-study/attack-chain.svg&quot; alt=&quot;Full attack chain: register a test account, sign in, leak the session JWT through the error page, validate the signature offline with the guessable secret, replay the token to get a fresh access token, call GET /me for full account takeover.&quot; loading=&quot;lazy&quot; /&gt;
  &lt;figcaption&gt;The leak becomes account takeover when the staging JWT secret is guessable.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;We ran the entire chain with curl against a live staging environment. We didn&apos;t need source code access to exploit it. We only needed it to discover the hardcoded secret in Terraform. An external attacker reaches the same outcome by guessing the secret or by intercepting a token through the error page leak.&lt;/p&gt;

&lt;h2 id=&quot;the-public-bucket&quot;&gt;The public bucket&lt;/h2&gt;

&lt;p&gt;The bucket is also enumerable:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;curl https://your-error-pages-bucket.s3.amazonaws.com/
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Returns a full &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ListBucketResult&lt;/code&gt; XML with every file, size, ETag, and last-modified timestamp, no authentication required.&lt;/p&gt;

&lt;p&gt;It&apos;s public because the error page origin uses an S3 &lt;strong&gt;website endpoint&lt;/strong&gt;, which forces public read access. OAC (Origin Access Control) only works with the REST API endpoint. The pages themselves are benign HTML, but the bucket is one IAM policy drift away from attacker-writable: a phishing form in a 502 page would be served by your own domain, from your own CloudFront distribution, with your own TLS certificate.&lt;/p&gt;

&lt;h2 id=&quot;the-fix&quot;&gt;The Fix&lt;/h2&gt;

&lt;p&gt;The error page behavior serves static HTML from S3. It doesn&apos;t need the viewer&apos;s auth token, cookies, query strings, or any other header.&lt;/p&gt;

&lt;p&gt;When you omit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origin_request_policy_id&lt;/code&gt; entirely, CloudFront sends only the minimum required headers to the origin. That is enough for an S3 website endpoint to fetch a file by path, and there is nothing left for S3 to reflect back.&lt;/p&gt;

&lt;p&gt;The simplest fix is to delete the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origin_request_policy_id&lt;/code&gt; line from every error page behavior. It shouldn&apos;t have been there in the first place.&lt;/p&gt;

&lt;p&gt;But if you want to be explicit about the intent (and make it harder for someone to re-add a permissive policy later thinking it was accidentally removed), define a policy that forwards nothing:&lt;/p&gt;

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_cloudfront_origin_request_policy&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;ErrorPagesNoAuth&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;ErrorPages-NoAuth&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;comment&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Error pages are static HTML, no viewer headers needed&quot;&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;headers_config&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;header_behavior&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;none&quot;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;cookies_config&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;cookie_behavior&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;none&quot;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;query_strings_config&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;query_string_behavior&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;none&quot;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then in every &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ordered_cache_behavior&lt;/code&gt; for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/custom_error_pages/*&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;ordered_cache_behavior&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;path_pattern&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;/custom_error_pages/*&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;target_origin_id&lt;/span&gt;         &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;error-pages&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;origin_request_policy_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_cloudfront_origin_request_policy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;ErrorPagesNoAuth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;viewer_protocol_policy&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;redirect-to-https&quot;&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;# ... rest unchanged&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Before fix:&lt;/span&gt;
curl https://your-api.com/custom_error_pages/502.html &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Authorization: Bearer TEST&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# → &amp;lt;ArgumentValue&amp;gt;Bearer TEST&amp;lt;/ArgumentValue&amp;gt;  ← LEAKED&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# After fix:&lt;/span&gt;
curl https://your-api.com/custom_error_pages/502.html &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Authorization: Bearer TEST&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# → &amp;lt;!DOCTYPE html&amp;gt;&amp;lt;html&amp;gt;...(your 502 page HTML)...  ← SAFE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;For defense in depth:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Switch the S3 origin from a public website endpoint to a private bucket with OAC. This closes the public listing vulnerability and eliminates the unencrypted HTTP origin protocol.&lt;/li&gt;
  &lt;li&gt;Set &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;error_caching_min_ttl = 0&lt;/code&gt; so error responses aren&apos;t cached at all.&lt;/li&gt;
  &lt;li&gt;Set &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;viewer_protocol_policy = &quot;redirect-to-https&quot;&lt;/code&gt; on the error page behavior so the path can&apos;t be accessed over plain HTTP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Forwarding headers to error pages &lt;em&gt;is&lt;/em&gt; valid when the origin needs the viewer&apos;s identity: a Lambda@Edge function rendering personalized error pages, a Cognito-gated bucket, or a debugging service logging which user hit the error. In those cases the origin is an application that can safely consume auth headers. If your error page origin is S3, the answer is always &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;header_behavior = &quot;none&quot;&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;what-we-shipped&quot;&gt;What we shipped&lt;/h2&gt;

&lt;p&gt;Sixteen distributions closed. The JWT reflection path is gone across the client&apos;s account, and HTTP-to-HTTPS is enforced on every error-page behavior. The one residual risk (the public S3 website endpoint) is on the follow-up list.&lt;/p&gt;

&lt;h2 id=&quot;how-to-check-if-youre-vulnerable&quot;&gt;How to Check If You&apos;re Vulnerable&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Find your error page path.&lt;/strong&gt; Look for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;custom_error_response&lt;/code&gt; blocks in your CloudFront distribution. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;response_page_path&lt;/code&gt; tells you where the error pages live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Send a request with an auth header.&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;curl &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; https://your-domain.com/custom_error_pages/502.html &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Authorization: Bearer CHECK_THIS_TOKEN&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Check the response.&lt;/strong&gt; If you see &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;ArgumentValue&amp;gt;Bearer CHECK_THIS_TOKEN&amp;lt;/ArgumentValue&amp;gt;&lt;/code&gt; in the XML, you&apos;re vulnerable. If you see your HTML error page, you&apos;re fine. A 403 means the path doesn&apos;t exist or the behavior doesn&apos;t allow GET, which is also fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Check all your distributions.&lt;/strong&gt; If you use the same Terraform module or copy-paste pattern across distributions, check every one. In one engagement we found the majority of distributions sharing the same misconfiguration. The one you check might be fine while a dozen others are leaking.&lt;/p&gt;

&lt;h2 id=&quot;check-every-distribution&quot;&gt;Check every distribution&lt;/h2&gt;

&lt;p&gt;Nobody asked whether a static HTML bucket needed the same headers as an authenticated API. If you&apos;re running CloudFront with custom S3 error pages, spend sixty seconds running the curl command above against each distribution.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A note on responsible testing: every test account created during this engagement was registered with a non-existent &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@example.com&lt;/code&gt; address, used only for curl-based proof-of-concept, and cleaned up after the findings were documented. No real user sessions were intercepted or replayed. The full chain was proven end-to-end using our own credentials against a staging environment with explicit authorization from the infrastructure owner.&lt;/em&gt;&lt;/p&gt;

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;we-could-scan-your-cloudfront-for-this&quot;&gt;We Could Scan Your CloudFront For This&lt;/h2&gt;

  &lt;p&gt;If your infrastructure has CloudFront distributions with custom S3 error pages and the same Terraform block copy-pasted across environments, one curl command will show whether your team has the same leak. &lt;strong&gt;Clearview Team&lt;/strong&gt; runs the scan across every distribution in your account, walks the full exploitation chain wherever a leaked token gives real access, and hands the fix back as a pull request. No secret is guessed and no live session replayed without your explicit sign-off. Send us the AWS account and we will scope it.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;mailto:info@clearview.team?subject=CloudFront%20audit%20enquiry&quot;&gt;Scope a CloudFront audit →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <category term="aws" />
    <category term="cloudfront" />
    <category term="s3" />
    <category term="terraform" />
    <category term="jwt" />
    <category term="security" />
    <category term="api-security" />
    <category term="web-api-security" />
    <category term="aws-devops" />
    <category term="auth-architecture" />
    <category term="case-study" />
    
  </entry>
  
  <entry>
    <title>How a JWT Audience Map Saved a CORS Mistake: A Defense-in-Depth Case Study</title>
    <link href="https://blog.clearview.team/2026/how-a-jwt-audience-map-saved-a-cors-mistake-defense-in-depth-case-study/" />
    <id>https://blog.clearview.team/2026/how-a-jwt-audience-map-saved-a-cors-mistake-defense-in-depth-case-study/</id>
    <published>2026-07-22T11:00:00+02:00</published>
    <updated>2026-07-22T11:00:00+02:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>On a client&apos;s API, a CORS wildcard looked like a HIGH severity finding — until we tried to actually exploit it. A second, independent JWT audience check turned a trivially exploitable bug into one that required a much harder prerequisite.</summary>
    <content type="html">&lt;p&gt;While pen-testing a client&apos;s API, we found a CORS policy that accepted any subdomain of the client&apos;s main domain, with credentials, on every kind of HTTP request. Textbook misconfiguration. We were drafting it as a HIGH severity finding when we tried to run the exploit ourselves, and discovered a second, independent control that stopped the attack at a different layer.&lt;/p&gt;

&lt;p&gt;Two terms before we go further:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;CORS&lt;/strong&gt; — the browser&apos;s rule about which &lt;em&gt;other&lt;/em&gt; websites are allowed to send authenticated requests to your API. Mis-set, it lets any site act on a logged-in user&apos;s behalf.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;JWT&lt;/strong&gt; — JSON Web Token. The short signed string the API hands a user after they log in. Every subsequent request carries it as proof of who the user is. The token has an &lt;em&gt;audience&lt;/em&gt; field that names which application the token was minted for, and the API can refuse a token presented to the wrong audience.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two independent controls — the stricter one held while the weaker one was broken.&lt;/p&gt;

&lt;h2 id=&quot;the-cors-misconfiguration&quot;&gt;The CORS Misconfiguration&lt;/h2&gt;

&lt;p&gt;The Express CORS middleware allowed any subdomain through a regex:&lt;/p&gt;

&lt;div class=&quot;language-typescript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// cors.loader.ts&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;pattern&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sr&quot;&gt;/^https:&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\/\/([&lt;/span&gt;&lt;span class=&quot;sr&quot;&gt;a-z0-9-&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;sr&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\.)?&lt;/span&gt;&lt;span class=&quot;sr&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\.&lt;/span&gt;&lt;span class=&quot;sr&quot;&gt;example&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\.&lt;/span&gt;&lt;span class=&quot;sr&quot;&gt;org$/&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This matches any subdomain, including ones that do not exist yet, ones that could be registered by attackers, and ones that could be taken over via a dangling DNS record. We confirmed the breadth with a small matrix:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Evil subdomain — accepted&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-sI&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$API&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/config&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Origin: https://evil.client.example.org&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# access-control-allow-origin: https://evil.client.example.org&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# access-control-allow-credentials: true&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# Punycode subdomain — accepted&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-sI&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$API&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/config&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Origin: https://xn--evil.client.example.org&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# access-control-allow-origin: https://xn--evil.client.example.org&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# Numeric prefix — accepted&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-sI&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$API&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/config&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Origin: https://123evil.client.example.org&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# access-control-allow-origin: https://123evil.client.example.org&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;CORS with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;credentials: true&lt;/code&gt;, wildcard subdomains, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DELETE&lt;/code&gt; listed in the preflight &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Access-Control-Allow-Methods&lt;/code&gt;. Every box on the checklist of &quot;trivially exploitable CORS misconfiguration.&quot;&lt;/p&gt;

&lt;p&gt;We were about to write &lt;em&gt;HIGH — any subdomain can steal authenticated session data&lt;/em&gt;. We tried to actually exploit it first.&lt;/p&gt;

&lt;h2 id=&quot;the-exploit-that-failed&quot;&gt;The Exploit That Failed&lt;/h2&gt;

&lt;p&gt;For the exploit to work in a real browser, the attacker needs to make authenticated requests from their evil subdomain. With JWT auth (Authorization header, not cookies), the attacker first has to obtain a token from the evil origin — either by signing in there, or by stealing a token from the legitimate frontend&apos;s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;localStorage&lt;/code&gt; and replaying it.&lt;/p&gt;

&lt;p&gt;We tried signing in from the evil origin:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;curl &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-X&lt;/span&gt; POST &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$API&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/auth/signin&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Content-Type: application/json&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Origin: https://evil.client.example.org&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;{&quot;email&quot;:&quot;test@example.org&quot;,&quot;password&quot;:&quot;Secret123&quot;}&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;success&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;message&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Unknown origin&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;CORS &lt;em&gt;accepted&lt;/em&gt; the origin. The API &lt;em&gt;rejected&lt;/em&gt; the sign-in. Something independent of CORS was checking.&lt;/p&gt;

&lt;h2 id=&quot;the-jwt-audience-map&quot;&gt;The JWT Audience Map&lt;/h2&gt;

&lt;p&gt;Digging into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jwt.config.ts&lt;/code&gt; surfaced this:&lt;/p&gt;

&lt;div class=&quot;language-typescript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;audienceMap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Record&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;// Production&lt;/span&gt;
  &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;https://app.client.example&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;main-app&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;https://admin.client.example&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;         &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;admin-app&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;https://partners.client.example&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;  &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;partner-portal&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;// Staging&lt;/span&gt;
  &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;https://app.staging.client.example&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;main-app&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;https://partners.staging.client.example&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;  &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;partner-portal&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;https://admin.staging.client.example&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;         &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;admin-app&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;// API-internal calls&lt;/span&gt;
  &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;https://api.staging.client.example&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;api-internal&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;resolveAudience&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;origin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;undefined&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;origin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;api-internal&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;audienceMap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;origin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;// null = rejected&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Seven explicit origins. Anything else returns &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;null&lt;/code&gt;, which the calling code translates into a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;401 Unknown origin&lt;/code&gt;. The CORS policy says &quot;any subdomain is fine.&quot; The JWT system says &quot;only these seven specific origins get tokens.&quot; Two access-control systems with conflicting policies — and the stricter one wins.&lt;/p&gt;

&lt;h2 id=&quot;testing-every-authenticated-path&quot;&gt;Testing Every Authenticated Path&lt;/h2&gt;

&lt;p&gt;We needed to know whether the audience check covers every authenticated operation or just sign-in:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# 1. Get a token from a legitimate origin.&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;TOKEN&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;$(&lt;/span&gt;curl &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-X&lt;/span&gt; POST &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$API&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/auth/signin&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Origin: https://partners.staging.client.example&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;{&quot;email&quot;:&quot;test@example.org&quot;,&quot;password&quot;:&quot;Secret123&quot;}&apos;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  | jq &lt;span class=&quot;nt&quot;&gt;-r&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;.data.access_token&apos;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# 2. Try the token from the evil origin.&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$API&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/auth/me&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Authorization: Bearer &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$TOKEN&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Origin: https://evil.client.example.org&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# → &quot;Unknown origin&quot;&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# 3. Try with no Origin header at all.&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$API&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/auth/me&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Authorization: Bearer &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$TOKEN&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# → &quot;Audience mismatch&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;#   (token has aud=partner-portal, no origin resolves to api-internal, mismatch)&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# 4. Try with a different legitimate origin.&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$API&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/auth/me&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Authorization: Bearer &lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$TOKEN&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Origin: https://app.staging.client.example&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# → &quot;Audience mismatch&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;#   (partner-portal ≠ main-app)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Every path blocked. The audience check fires on every authenticated request, not just sign-in. A token minted for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partner-portal&lt;/code&gt; only works when sent with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partners.staging.client.example&lt;/code&gt; Origin header — any other origin (evil, no-origin, different legitimate origin) returns 401.&lt;/p&gt;

&lt;h2 id=&quot;the-refresh-token-path&quot;&gt;The Refresh Token Path&lt;/h2&gt;

&lt;p&gt;Token refresh has the same check:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;curl &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-X&lt;/span&gt; POST &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$API&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/auth/refresh-token&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Origin: https://evil.client.example.org&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;{&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;refresh_token&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$REFRESH&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;}&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# → &quot;Unknown origin&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The attacker cannot refresh a stolen token from an evil origin either.&lt;/p&gt;

&lt;h2 id=&quot;what-is-still-vulnerable&quot;&gt;What Is Still Vulnerable&lt;/h2&gt;

&lt;p&gt;The audience check only runs on authenticated endpoints. Public endpoints do not need it, and do not get it:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Public config — readable from any CORS-accepted origin.&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$API&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/config&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Origin: https://evil.client.example.org&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# 200 OK — feature flags returned&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# Public catalog — same shape.&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-s&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$API&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/catalog/&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Origin: https://evil.client.example.org&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# 200 OK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Public data is readable from any origin that the CORS policy lets through. The data itself is not sensitive — feature flags and public catalog entries — so the impact is low. The client&apos;s product owner confirmed this explicitly.&lt;/p&gt;

&lt;p&gt;The real remaining risk is XSS on a legitimate subdomain. If an attacker achieves XSS on, say, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partners.staging.client.example&lt;/code&gt;, they can read the JWT out of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;localStorage&lt;/code&gt; and use it from script running on that legitimate origin. Neither control fires. The audience check sees a legitimate origin and CORS sees an approved one. The two controls only stop arbitrary origins, not legitimate origins compromised via a different bug.&lt;/p&gt;

&lt;h2 id=&quot;the-severity-downgrade&quot;&gt;The Severity Downgrade&lt;/h2&gt;

&lt;p&gt;We originally drafted the CORS wildcard as HIGH. After the exploitation attempt, we downgraded it to MEDIUM, with the residual risk path documented explicitly:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Attack Scenario&lt;/th&gt;
      &lt;th&gt;CORS&lt;/th&gt;
      &lt;th&gt;JWT Audience&lt;/th&gt;
      &lt;th&gt;Net Result&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Sign in from an evil subdomain&lt;/td&gt;
      &lt;td&gt;allowed&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;blocked&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;blocked&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Use a stolen token from an evil origin&lt;/td&gt;
      &lt;td&gt;allowed&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;blocked&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;blocked&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Refresh a stolen token from evil origin&lt;/td&gt;
      &lt;td&gt;allowed&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;blocked&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;blocked&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Read public data from an evil subdomain&lt;/td&gt;
      &lt;td&gt;allowed&lt;/td&gt;
      &lt;td&gt;n/a&lt;/td&gt;
      &lt;td&gt;succeeds (not sensitive)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Token use from XSS&apos;d legitimate origin&lt;/td&gt;
      &lt;td&gt;allowed&lt;/td&gt;
      &lt;td&gt;allowed&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;vulnerable&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;We still recommended the CORS fix, and it landed in the same sprint. Defense in depth means not relying on one independent control to do the work of two. Without the audience map, the CORS bug would have been trivially exploitable. With it, an attacker first needs script execution on a legitimate origin (XSS on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partners.staging.client.example&lt;/code&gt; or similar), which is a much higher bar.&lt;/p&gt;

&lt;h2 id=&quot;why-two-independent-controls-worked&quot;&gt;Why Two Independent Controls Worked&lt;/h2&gt;

&lt;p&gt;The CORS policy and the JWT audience map were written at different times by different people for different reasons. CORS was set up early in the project, when the team was thinking about subdomain flexibility. The audience map was added later, when multi-portal authentication came in. Neither was deliberately a &quot;backup&quot; for the other.&lt;/p&gt;

&lt;p&gt;The two controls do not need to know about each other. CORS asks whether the browser is allowed to send the request; the audience check asks whether the token is valid for this origin. Different questions, enforced in different places. When one is weaker, the other holds — which is what happened here.&lt;/p&gt;

&lt;aside class=&quot;callout&quot;&gt;
  &lt;p&gt;&lt;strong&gt;Separate but matched lists.&lt;/strong&gt; The cleanest version of this system has the CORS allowlist and the JWT audience map sharing a single source-of-truth file. Drift between them is exactly the situation we walked into — CORS says yes, JWT says no, and the team is not sure which one defines the real attack surface. We unified the two on this engagement by reading both lists from the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origins.config.ts&lt;/code&gt; at boot.&lt;/p&gt;
&lt;/aside&gt;

&lt;h2 id=&quot;two-things-this-engagement-changed-for-us&quot;&gt;Two things this engagement changed for us&lt;/h2&gt;

&lt;p&gt;First: try to exploit a finding end to end before assigning severity. The path from &quot;this check is missing&quot; to &quot;I can steal user data&quot; often has unexpected obstacles, and the severity should reflect the real impact, not the impact of the broken control in isolation.&lt;/p&gt;

&lt;p&gt;Second: unify the CORS allowlist and the JWT audience list into one source-of-truth file, two readers. When the two lists drift, defense in depth becomes defense in name only. On this engagement, we consolidated both into a single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origins.config.ts&lt;/code&gt; at boot; the CORS middleware and the audience resolver now read from the same list.&lt;/p&gt;

&lt;p&gt;The residual risk (XSS on a legitimate subdomain) went into the report explicitly, because a layered defense that holds against arbitrary origins still needs the reader to know what would open the attack.&lt;/p&gt;

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;we-could-run-this-pass-for-your-team&quot;&gt;We Could Run This Pass For Your Team&lt;/h2&gt;

  &lt;p&gt;If your API has CORS and JWT both in the request path, and you are not certain whether the two layers reinforce each other or disagree, we have walked into a few of those. &lt;strong&gt;Clearview Team&lt;/strong&gt; can audit yours in a sprint — you walk away with severity ratings that reflect what an attacker could actually pull off, and the fixes go in alongside the report.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;mailto:info@clearview.team?subject=API%20defense-in-depth%20pen-test%20enquiry&quot;&gt;Scope an engagement →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <category term="jwt" />
    <category term="cors" />
    <category term="defense-in-depth" />
    <category term="security" />
    <category term="devsecops" />
    <category term="api-security" />
    <category term="express" />
    <category term="web-api-security" />
    <category term="auth-architecture" />
    <category term="case-study" />
    
  </entry>
  
</feed>
