<?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: gokul</title>
    <description>The latest articles on DEV Community by gokul (@gokulnh).</description>
    <link>https://dev.to/gokulnh</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%2F4062155%2F406259a3-a275-4338-a7e6-e9208f0f4768.png</url>
      <title>DEV Community: gokul</title>
      <link>https://dev.to/gokulnh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gokulnh"/>
    <language>en</language>
    <item>
      <title>When Your Local API Needs to Talk to a Real Device</title>
      <dc:creator>gokul</dc:creator>
      <pubDate>Fri, 14 Aug 2026 05:23:36 +0000</pubDate>
      <link>https://dev.to/gokulnh/when-your-local-api-needs-to-talk-to-a-real-device-1amb</link>
      <guid>https://dev.to/gokulnh/when-your-local-api-needs-to-talk-to-a-real-device-1amb</guid>
      <description>&lt;p&gt;There is a point in development where testing entirely on your laptop stops being enough.&lt;/p&gt;

&lt;p&gt;The API works.&lt;/p&gt;

&lt;p&gt;The frontend works.&lt;/p&gt;

&lt;p&gt;The emulator works.&lt;/p&gt;

&lt;p&gt;Then you pick up an actual phone and discover that the environment you built everything around doesn't exist on the device.&lt;/p&gt;

&lt;p&gt;The backend is running on &lt;code&gt;localhost&lt;/code&gt;, but the phone can't reach it.&lt;/p&gt;

&lt;p&gt;This is less of an application bug and more of a networking problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;localhost&lt;/code&gt; doesn't mean what you think it means
&lt;/h2&gt;

&lt;p&gt;Suppose your API is running on:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;From the laptop running the API, that address makes sense.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;localhost&lt;/code&gt; always refers to the machine making the request.&lt;/p&gt;

&lt;p&gt;So when a phone tries to access:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;it is looking for a service running on &lt;strong&gt;the phone itself&lt;/strong&gt;, not your laptop.&lt;/p&gt;

&lt;p&gt;That's why replacing a production API URL with &lt;code&gt;localhost&lt;/code&gt; often fails during mobile testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traditional solution: local network access
&lt;/h2&gt;

&lt;p&gt;One option is to make the backend accessible through your laptop's local IP address.&lt;/p&gt;

&lt;p&gt;The architecture becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Physical Phone
       |
       | Wi-Fi
       |
       v
Developer Laptop
       |
       v
Local API :8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can work well.&lt;/p&gt;

&lt;p&gt;But it introduces several dependencies.&lt;/p&gt;

&lt;p&gt;The devices need to be on compatible networks, the server needs to listen on the appropriate interface, and firewall settings may prevent incoming connections.&lt;/p&gt;

&lt;p&gt;It can also become inconvenient when testing with someone else's device.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes when the device isn't on your network?
&lt;/h2&gt;

&lt;p&gt;This is where development gets more interesting.&lt;/p&gt;

&lt;p&gt;Imagine a QA tester has the mobile application on their phone, but your API is running on your laptop at home.&lt;/p&gt;

&lt;p&gt;They aren't connected to your local network.&lt;/p&gt;

&lt;p&gt;Giving them your laptop's private IP address won't solve the problem.&lt;/p&gt;

&lt;p&gt;You need a publicly reachable endpoint that can forward requests back to the development machine.&lt;/p&gt;

&lt;p&gt;That's the basic job of a tunnel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Real Device
     |
     v
Public HTTPS Endpoint
     |
     v
Tunnel
     |
     v
Developer Laptop
     |
     v
Local API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API remains local.&lt;/p&gt;

&lt;p&gt;Only the network path changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this can be better than deploying every change
&lt;/h2&gt;

&lt;p&gt;A common alternative is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Change code
   ↓
Commit
   ↓
Build
   ↓
Deploy
   ↓
Test on phone
   ↓
Find a bug
   ↓
Repeat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For early development, that loop can be unnecessarily slow.&lt;/p&gt;

&lt;p&gt;A local backend with a temporary public endpoint can look more like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Change code
   ↓
Backend reloads
   ↓
Test on phone
   ↓
Fix
   ↓
Test again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That shorter loop is valuable when you're actively developing an API.&lt;/p&gt;

&lt;p&gt;Tools such as &lt;a href="https://21tunnel.com/" rel="noopener noreferrer"&gt;21tunnel&lt;/a&gt; can provide a public endpoint for a local HTTP service, allowing a physical device to reach the backend without moving the entire backend to a hosted environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is useful beyond mobile apps
&lt;/h2&gt;

&lt;p&gt;The same pattern appears whenever an external system needs to reach something running locally.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A QA device testing an API&lt;/li&gt;
&lt;li&gt;A teammate reviewing a development build&lt;/li&gt;
&lt;li&gt;A third-party integration calling a local endpoint&lt;/li&gt;
&lt;li&gt;A physical IoT device communicating with a development server&lt;/li&gt;
&lt;li&gt;A client testing an unfinished application&lt;/li&gt;
&lt;li&gt;A remote developer accessing a temporary service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The common requirement is always the same:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Something outside your machine needs to reach a service inside your machine.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Be careful with authentication
&lt;/h2&gt;

&lt;p&gt;Making the API reachable from the internet changes the security model.&lt;/p&gt;

&lt;p&gt;A local development API may have been designed under the assumption that only the developer can access it.&lt;/p&gt;

&lt;p&gt;That assumption disappears when the endpoint becomes public.&lt;/p&gt;

&lt;p&gt;Before exposing a development API, check:&lt;/p&gt;

&lt;h3&gt;
  
  
  Authentication
&lt;/h3&gt;

&lt;p&gt;Does the API require authentication?&lt;/p&gt;

&lt;h3&gt;
  
  
  Authorization
&lt;/h3&gt;

&lt;p&gt;Can a test user access administrative functionality?&lt;/p&gt;

&lt;h3&gt;
  
  
  Sensitive data
&lt;/h3&gt;

&lt;p&gt;Does the development database contain real user information?&lt;/p&gt;

&lt;h3&gt;
  
  
  Debug endpoints
&lt;/h3&gt;

&lt;p&gt;Are debugging or internal endpoints enabled?&lt;/p&gt;

&lt;h3&gt;
  
  
  Secrets
&lt;/h3&gt;

&lt;p&gt;Are development credentials stored in responses or logs?&lt;/p&gt;

&lt;p&gt;A tunnel solves connectivity. It doesn't automatically solve application security.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTPS matters too
&lt;/h2&gt;

&lt;p&gt;Modern mobile applications and third-party integrations can have restrictions around insecure HTTP connections.&lt;/p&gt;

&lt;p&gt;A public HTTPS endpoint can make development closer to the conditions the application will encounter outside the local environment.&lt;/p&gt;

&lt;p&gt;This is particularly useful when testing functionality that depends on secure network connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about VPNs?
&lt;/h2&gt;

&lt;p&gt;A VPN can also connect devices to a private development network.&lt;/p&gt;

&lt;p&gt;That's useful when an organization needs controlled access to internal services.&lt;/p&gt;

&lt;p&gt;But a VPN can be more infrastructure than a developer needs for a quick test.&lt;/p&gt;

&lt;p&gt;If the requirement is simply:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I need my phone to reach this API for the next two hours."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;a temporary tunnel may be simpler.&lt;/p&gt;

&lt;p&gt;If the requirement is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Our entire development team needs permanent private access to internal services."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;then a VPN or other private networking architecture may make more sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  The important distinction
&lt;/h2&gt;

&lt;p&gt;Tunneling isn't about making localhost magically public.&lt;/p&gt;

&lt;p&gt;It's about creating a controlled network path between an external client and a service that remains local.&lt;/p&gt;

&lt;p&gt;That distinction helps developers choose the right architecture.&lt;/p&gt;

&lt;p&gt;For short-lived development and testing, exposing only the required service can be much more practical than deploying an entire environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  A useful mental model
&lt;/h2&gt;

&lt;p&gt;When debugging connectivity problems, ask one question first:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where is the client, and where is the server?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If both are on the same machine, &lt;code&gt;localhost&lt;/code&gt; is straightforward.&lt;/p&gt;

&lt;p&gt;If they're on the same local network, private network addressing may be enough.&lt;/p&gt;

&lt;p&gt;If they're on completely different networks, you'll need some form of reachable network path.&lt;/p&gt;

&lt;p&gt;That could be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Public deployment&lt;/li&gt;
&lt;li&gt;VPN&lt;/li&gt;
&lt;li&gt;Reverse proxy&lt;/li&gt;
&lt;li&gt;Port forwarding&lt;/li&gt;
&lt;li&gt;Tunnel&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The right choice depends on the duration, security requirements, and purpose of the connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The development environment doesn't always need to move
&lt;/h2&gt;

&lt;p&gt;One of the useful ideas behind tunneling is simple:&lt;/p&gt;

&lt;p&gt;Your code can stay where you're developing it.&lt;/p&gt;

&lt;p&gt;You don't necessarily need to deploy an application just because another device needs to communicate with it.&lt;/p&gt;

&lt;p&gt;For mobile developers, that can mean faster testing on real hardware and a much shorter feedback loop.&lt;/p&gt;

&lt;p&gt;The goal isn't to replace staging or production infrastructure.&lt;/p&gt;

&lt;p&gt;It's to remove unnecessary networking friction while you're still building the thing.&lt;/p&gt;

</description>
      <category>api</category>
    </item>
    <item>
      <title>Your rate limiter is broken behind a tunnel — the X-Forwarded-For problem</title>
      <dc:creator>gokul</dc:creator>
      <pubDate>Thu, 13 Aug 2026 06:46:41 +0000</pubDate>
      <link>https://dev.to/gokulnh/your-rate-limiter-is-broken-behind-a-tunnel-the-x-forwarded-for-problem-3923</link>
      <guid>https://dev.to/gokulnh/your-rate-limiter-is-broken-behind-a-tunnel-the-x-forwarded-for-problem-3923</guid>
      <description>&lt;p&gt;You put your app behind a tunnel (or any reverse proxy) to test&lt;br&gt;
   webhooks. Everything works. Then you notice something odd in your logs:&lt;br&gt;
   every single request comes from the same IP address.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Congratulations, you've met the X-Forwarded-For problem.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  What actually happens
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; When a request flows through a tunnel, the TCP connection to your app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;comes from the relay, not the real client. So &lt;code&gt;request.remote_addr&lt;/code&gt; —&lt;br&gt;
   the value your framework uses for rate limiting, IP logging,&lt;br&gt;
   geo-blocking, brute-force detection — is the relay's address. For every&lt;br&gt;
   request. From every user.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; The consequences are quiet and nasty:

  Your rate limiter now rate-limits the relay, not the client. One
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;aggressive user trips the limit and everyone gets blocked. Or worse, the&lt;br&gt;
   limit is per-IP and effectively unlimited, because each relay node looks&lt;br&gt;
   like one "user."&lt;br&gt;
     * Your access logs are fiction. Security review of an incident?&lt;br&gt;
   Every entry says the same address.&lt;br&gt;
     * IP allowlists silently break. "Only allow my office IP" now&lt;br&gt;
   allows nothing, or everything, depending on how it's wired.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix (and its trap)
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; The proxy already tells you the real client IP — in the
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;code&gt;X-Forwarded-For&lt;/code&gt; header. Every framework has a setting to trust it.&lt;br&gt;
   Flask: &lt;code&gt;ProxyFix&lt;/code&gt;. Express: &lt;code&gt;app.set('trust proxy', ...)&lt;/code&gt;. Rails,&lt;br&gt;
   Django, Laravel: equivalents exist.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Here's the trap: trust that header blindly and anyone can spoof
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;it. A client can send &lt;code&gt;X-Forwarded-For: 1.2.3.4&lt;/code&gt; directly, and if your&lt;br&gt;
   app believes headers from anyone, your rate limiter is bypassed with a&lt;br&gt;
   curl flag.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; The correct setup has two halves:

 1. Trust `X-Forwarded-For` only when the immediate connection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;comes from a proxy you control (your tunnel relay, your load balancer).&lt;br&gt;
     2. Strip or ignore the header on direct connections.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Most frameworks express this as "trusted proxies" — a list of proxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;IPs whose forwarded headers you believe. Set it. It's five minutes of&lt;br&gt;
   config that determines whether your security features are real or&lt;br&gt;
   decorative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more in the tunnel era
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Tunnels used to be a demo-day tool. Now they're how teams test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;webhooks, preview for clients, and let AI agents reach local&lt;br&gt;
   environments — often for days at a time, fronting apps with real auth&lt;br&gt;
   and real rate limits. Every one of those apps is behind a proxy, whether&lt;br&gt;
   the developer thought about it or not.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; If you're picking a tunnel, this is one of the details that separates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;serious ones: does the relay set and document its forwarded headers? We&lt;br&gt;
   built &lt;a href="https://21tunnel.com" rel="noopener noreferrer"&gt;21tunnel&lt;/a&gt; to pass real client IPs through&lt;br&gt;
   correctly (and gate tunnels at the edge when you don't want the internet&lt;br&gt;
   reaching your app at all) — but whatever you use, spend the five minutes&lt;br&gt;
   on trusted proxies.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Your rate limiter will thank you.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Anyone with your tunnel URL can hit your dev machine — the attack surface nobody mentions</title>
      <dc:creator>gokul</dc:creator>
      <pubDate>Wed, 12 Aug 2026 07:24:48 +0000</pubDate>
      <link>https://dev.to/gokulnh/anyone-with-your-tunnel-url-can-hit-your-dev-machine-the-attack-surface-nobody-mentions-ned</link>
      <guid>https://dev.to/gokulnh/anyone-with-your-tunnel-url-can-hit-your-dev-machine-the-attack-surface-nobody-mentions-ned</guid>
      <description>&lt;p&gt;You start your dev server, open a tunnel to show a teammate your&lt;br&gt;
   progress, and get a public URL. You paste it in Slack. They click it,&lt;br&gt;
   see the app, great. Meeting ends.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Who else has that URL?

 Your teammate, obviously. Anyone who scrolls back in that channel. Any
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;bot that scrapes the link. Anyone who guesses the subdomain if it's a&lt;br&gt;
   common word. And here's the uncomfortable part: that URL doesn't point&lt;br&gt;
   to a hardened production deployment. It points to your laptop. To the&lt;br&gt;
   branch you're halfway through breaking. To debug mode, with stack traces&lt;br&gt;
   on, maybe with an admin panel that skips auth in development.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ## Public dev URLs are real URLs

 We treat tunnel URLs as disposable, but for the hours they're live
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;they're genuine endpoints on the public internet, fronting machines that&lt;br&gt;
   were never meant to face it.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; The failure modes aren't exotic:

 * Debug endpoints leaking environment variables through an error page
 * Admin panels that "temporarily" bypass login
 * Webhook receivers that happily process forged payloads from anyone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;who finds the URL&lt;br&gt;
     * API keys in a &lt;code&gt;.env&lt;/code&gt; file one directory-traversal bug away from&lt;br&gt;
   disclosure&lt;br&gt;
     * The tunnel you opened on Friday that's still running on Monday,&lt;br&gt;
   pointed at a branch that no longer exists&lt;/p&gt;

&lt;p&gt;None of this needs a sophisticated attacker. It needs a URL, and URLs leak.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ## What responsible tunnel hygiene looks like

 **Short-lived by default.** A tunnel should be something you open for
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;a purpose and close when the purpose is done — not infrastructure that&lt;br&gt;
   accretes. If your tool ties URLs to expiring keys, use that. An expired&lt;br&gt;
   URL is a dead end for anyone who finds it later.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; **Expose one port, not your machine.** If the tunnel can serve your
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;whole box, a config mistake becomes a much bigger mistake.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; **Gate it when it matters.** If you're showing a client something or
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;testing against a third-party service for days, put the tunnel behind&lt;br&gt;
   sign-in at the edge, before traffic ever reaches your app. Your&lt;br&gt;
   application code shouldn't have to know it's being protected.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; **Scoped keys for AI agents.** If you're handing tunnel access to an
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;AI coding agent — and more teams are every month — don't give it your&lt;br&gt;
   master credentials. Give it a key that can open exactly one tunnel, and&lt;br&gt;
   revoke it when the task ends. An agent with a permanent tunnel and a&lt;br&gt;
   master key is an intern with root access.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ## The takeaway

 Whatever tool you use, the principle holds: a public URL is a real
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;URL. Treat your dev machine like the internet can see it — because for&lt;br&gt;
   as long as that tunnel is open, it can.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ---

 *Full disclosure: I work on [21tunnel](https://21tunnel.com), an
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;open-source tunnel built around this model — scoped, short-lived keys,&lt;br&gt;
   cascade revoke, and edge auth that gates any tunnel behind Google&lt;br&gt;
   sign-in with zero code changes.*&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>Webhook signature verification in Node, Python, and Rust — the raw-body trap</title>
      <dc:creator>gokul</dc:creator>
      <pubDate>Tue, 11 Aug 2026 07:57:48 +0000</pubDate>
      <link>https://dev.to/gokulnh/webhook-signature-verification-in-node-python-and-rust-the-raw-body-trap-l4c</link>
      <guid>https://dev.to/gokulnh/webhook-signature-verification-in-node-python-and-rust-the-raw-body-trap-l4c</guid>
      <description>&lt;p&gt;Every serious webhook provider — Stripe, GitHub, Shopify — signs deliveries&lt;br&gt;
   with HMAC. Verifying that signature is your only proof an event came from the&lt;br&gt;
   provider and not from someone who found your endpoint URL.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; And everyone hits the same trap: **you must verify against the raw request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;body, before any parsing.** Re-serialized JSON is not byte-identical to what&lt;br&gt;
   was sent. One byte of difference — a space, key ordering — and every signature&lt;br&gt;
   check fails with errors that point everywhere except the real cause.&lt;/p&gt;

&lt;p&gt;Here's the correct pattern in three languages.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ## Node (Express)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ```js
 const crypto = require('crypto');
 const express = require('express');
 const app = express();

 app.post('/webhooks/stripe',
   express.raw({ type: 'application/json' }), // raw body FIRST
   (req, res) =&amp;gt; {
     const sig = req.headers['stripe-signature'];
     const expected = crypto
       .createHmac('sha256', process.env.WEBHOOK_SECRET)
       .update(req.body) // Buffer, not parsed JSON
       .digest('hex');

     const received = sig.split(',').find(p =&amp;gt; p.startsWith('v1=')).slice(3);
     if (!crypto.timingSafeEqual(Buffer.from(expected),
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Buffer.from(received))) {&lt;br&gt;
           return res.status(400).send('invalid signature');&lt;br&gt;
         }&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;     const event = JSON.parse(req.body); // parse AFTER verifying
     res.status(200).send('ok');
   }
 );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
python
   Python (Flask)



   ```python
     import hashlib, hmac
     from flask import Flask, request, abort

     app = Flask(__name__)

     @app.post('/webhooks/github')
     def github_webhook():
         raw = request.get_data()  # raw bytes, before parsing
         expected = 'sha256=' + hmac.new(
             WEBHOOK_SECRET.encode(), raw, hashlib.sha256
         ).hexdigest()

         received = request.headers.get('X-Hub-Signature-256', '')
         if not hmac.compare_digest(expected, received):
             abort(400)

         event = request.get_json()  # parse AFTER verifying
         return 'ok'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Rust (axum)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;     &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HeaderMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
         &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;mac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Hmac&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Sha256&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="nf"&gt;.as_bytes&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
             &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"bad key"&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="n"&gt;mac&lt;/span&gt;&lt;span class="nf"&gt;.update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// raw Bytes, not Value&lt;/span&gt;
         &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="nf"&gt;.finalize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.into_bytes&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

         &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"x-hub-signature-256"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.to_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
             &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.strip_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sha256="&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
             &lt;span class="nf"&gt;.ok_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"missing sig"&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;received&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invalid signature"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
         &lt;span class="c1"&gt;// serde_json::from_slice(&amp;amp;body) AFTER this point&lt;/span&gt;
         &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three rules&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Raw body first. Any middleware that parses the body before verification
  breaks the signature.&lt;/li&gt;
&lt;li&gt;Constant-time comparison. timingSafeEqual / compare_digest — never == on
  signatures.&lt;/li&gt;
&lt;li&gt;Verify, then process, then 200. Acknowledging before the work commits is how
  events get lost on crashes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Full walkthrough including idempotency (dedupe on event ID) and retry testing:&lt;br&gt;
   testing webhooks locally, the complete guide&lt;br&gt;
   (&lt;a href="https://21tunnel.com/blog/test-stripe-webhooks-locally/" rel="noopener noreferrer"&gt;https://21tunnel.com/blog/test-stripe-webhooks-locally/&lt;/a&gt;)&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>tutorial</category>
      <category>security</category>
    </item>
    <item>
      <title>Exposing an MCP server to the internet — tunneling guide for Claude Desktop and Cursor</title>
      <dc:creator>gokul</dc:creator>
      <pubDate>Mon, 10 Aug 2026 11:02:22 +0000</pubDate>
      <link>https://dev.to/gokulnh/exposing-an-mcp-server-to-the-internet-tunneling-guide-for-claude-desktop-and-cursor-3ehb</link>
      <guid>https://dev.to/gokulnh/exposing-an-mcp-server-to-the-internet-tunneling-guide-for-claude-desktop-and-cursor-3ehb</guid>
      <description>&lt;p&gt;MCP servers usually run on your localhost — which means hosted&lt;br&gt;
   Claude, Cursor on another machine, or a teammate can't reach them.&lt;br&gt;
   The fix is a tunnel, but the details matter: which URL survives&lt;br&gt;
   restarts, how the config files actually look, and how to avoid&lt;br&gt;
   handing your AI tooling a credential it can burn down your account&lt;br&gt;
   with.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; This is the full walkthrough.

 ## Why your MCP server is unreachable

 The Model Context Protocol server you just wrote listens on a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;local port. That works beautifully when the client is on the same&lt;br&gt;
   machine. The moment the client is hosted Claude, a colleague's&lt;br&gt;
   Cursor, or anything off-box, localhost becomes a wall: NATs,&lt;br&gt;
   firewalls, and the simple fact that &lt;code&gt;localhost:8080&lt;/code&gt; means&lt;br&gt;
   something different on every machine.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; A tunnel solves it by running an agent on your machine that
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;holds a persistent outbound connection to a public edge. Traffic&lt;br&gt;
   to your public URL flows down that connection to your local port.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Step 1: Pick a URL that survives restarts

 This is the detail most guides skip, and the one that bites
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;first. MCP client configs hardcode the server URL. If your tunnel&lt;br&gt;
   hands you a new random address on every restart, your config&lt;br&gt;
   breaks silently every morning.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Use a named subdomain so the address is permanent:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ```bash
 mytunnel http 8080 --subdomain my-mcp
 # ✓ Tunnel active: https://my-mcp.21tunnel.com → 127.0.0.1:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;That URL is yours across restarts and reboots — paste it into&lt;br&gt;
configs once and stop thinking about it.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;

   Step 2: Wire up Claude Desktop

   Edit claude_desktop_config.json:



   ```json
     {
       "mcpServers": {
         "my-server": {
           "url": "https://my-mcp.21tunnel.com/sse"
         }
       }
     }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Restart Claude Desktop and the hosted client now talks to the MCP&lt;br&gt;
   server on your machine, through the tunnel.&lt;/p&gt;

&lt;p&gt;Step 3: Wire up Cursor&lt;/p&gt;

&lt;p&gt;Same shape in .cursor/mcp.json:&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="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;"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;"my-server"&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;"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://my-mcp.21tunnel.com/sse"&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;Now Cursor on any machine — yours, a teammate's, a CI box — can&lt;br&gt;
   reach the same server.&lt;/p&gt;

&lt;p&gt;Step 4: Don't hand the agent your master token&lt;/p&gt;

&lt;p&gt;If an AI agent is the one opening tunnels, pause here. The default&lt;br&gt;
   is exporting your tunnel service's account token into the agent's&lt;br&gt;
   environment. That token can typically open unlimited tunnels,&lt;br&gt;
   never expires, and shows up in transcripts and logs.&lt;/p&gt;

&lt;p&gt;The safer pattern: a master key that cannot open tunnels itself,&lt;br&gt;
   which the agent uses to mint its own scoped, short-lived child key&lt;br&gt;
   per project:&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;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;mytunnel &lt;span class="nb"&gt;eval &lt;/span&gt;mint &lt;span class="nt"&gt;--project&lt;/span&gt; mcp &lt;span class="nt"&gt;--ttl&lt;/span&gt; 4h &lt;span class="nt"&gt;--output-env&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
     mytunnel http 8080 &lt;span class="nt"&gt;--subdomain&lt;/span&gt; my-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent works autonomously, the credential dies on schedule, and&lt;br&gt;
   one click cascade-revokes everything it ever created.&lt;/p&gt;

&lt;p&gt;Step 5: Gate it if it matters&lt;/p&gt;

&lt;p&gt;A public MCP endpoint is a public door. For anything beyond a&lt;br&gt;
   quick test, enable edge auth so the URL only responds to signed-in&lt;br&gt;
   teammates or an allowlisted domain. One toggle in the dashboard,&lt;br&gt;
   no code changes.&lt;/p&gt;

&lt;p&gt;That's the whole setup: permanent URL, two config files, scoped&lt;br&gt;
   credentials, access control. The full guide with more config&lt;br&gt;
   variants: &lt;a href="https://21tunnel.com/blog/mcp-server-public-url-tunneling-guide/" rel="noopener noreferrer"&gt;exposing an MCP server to the internet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why We Rewrote Our Tunnel Stack in Rust</title>
      <dc:creator>gokul</dc:creator>
      <pubDate>Fri, 07 Aug 2026 11:34:37 +0000</pubDate>
      <link>https://dev.to/gokulnh/why-we-rewrote-our-tunnel-stack-in-rust-fom</link>
      <guid>https://dev.to/gokulnh/why-we-rewrote-our-tunnel-stack-in-rust-fom</guid>
      <description>&lt;h1&gt;
  
  
  Testing GitHub Webhooks Locally: A 5-Step Workflow That Actually Holds Up
&lt;/h1&gt;

&lt;p&gt;Setting up a GitHub webhook takes two minutes. You paste a URL into your repository settings, pick your events, and GitHub starts POSTing JSON.&lt;/p&gt;

&lt;p&gt;Testing that webhook properly is where everyone gets stuck — your development server runs on &lt;code&gt;localhost&lt;/code&gt;, and GitHub's servers cannot reach it. Worse, the failure modes that cause real production incidents (retries, duplicates, out-of-order deliveries) never show up in a happy-path test.&lt;/p&gt;

&lt;p&gt;After wiring GitHub webhooks more times than I can count, here's the workflow that actually holds up. Five steps, each addressing a failure I've seen take down a real integration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Get a Stable Public URL
&lt;/h2&gt;

&lt;p&gt;Any tunnel puts your localhost on the internet, but what actually matters is whether the URL &lt;strong&gt;survives a restart&lt;/strong&gt;. GitHub stores your webhook URL in the repository settings — if your tunnel hands you a fresh random address every time the agent restarts, you're editing webhook config every morning, and every mysteriously silent integration is just a stale URL.&lt;/p&gt;

&lt;p&gt;Use a named subdomain instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mytunnel http 3000 &lt;span class="nt"&gt;--subdomain&lt;/span&gt; gh-hooks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you a permanent &lt;code&gt;https://gh-hooks.21tunnel.com&lt;/code&gt; pointing at your local endpoint (e.g. &lt;code&gt;/webhooks/github&lt;/code&gt;). Paste that into your repo's webhook settings once, and it keeps working across restarts, reboots, and weekends.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ngrok and Cloudflare Tunnel solve the same problem with different tradeoffs — the requirement is the &lt;strong&gt;stable address&lt;/strong&gt;, not the specific tool.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 2: Verify Signatures Over the Raw Request Body
&lt;/h2&gt;

&lt;p&gt;GitHub signs every delivery with HMAC-SHA256, using the secret you set in the webhook configuration, and sends it in the &lt;code&gt;X-Hub-Signature-256&lt;/code&gt; header. This signature is your only proof that an event came from GitHub and not from someone who found your endpoint URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trap that burns afternoons:&lt;/strong&gt; if your web framework parses the JSON body &lt;em&gt;before&lt;/em&gt; your verification code runs, the bytes change. Re-serialized JSON is not byte-identical to what GitHub sent — the signature no longer matches, every event fails verification, and the error messages point everywhere except the real cause.&lt;/p&gt;

&lt;p&gt;The fix is the same in every framework: &lt;strong&gt;read the raw body first, verify the signature against those exact bytes, and only then parse the JSON.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Express&lt;/strong&gt; — use &lt;code&gt;express.raw&lt;/code&gt; on the webhook route, registered &lt;em&gt;before&lt;/em&gt; &lt;code&gt;express.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Django&lt;/strong&gt; — read &lt;code&gt;request.body&lt;/code&gt; before anything touches the parsed form data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One line of middleware ordering. Get it wrong and nothing works; get it right and you never think about it again.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Deduplicate on &lt;code&gt;X-GitHub-Delivery&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;GitHub promises &lt;strong&gt;at-least-once delivery&lt;/strong&gt;. If your endpoint times out, returns an error, or responds too slowly, the delivery comes back — sometimes even when nothing visibly failed.&lt;/p&gt;

&lt;p&gt;Every delivery carries a unique ID in the &lt;code&gt;X-GitHub-Delivery&lt;/code&gt; header:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Store it with a unique constraint in your database&lt;/li&gt;
&lt;li&gt;Do the actual work in the &lt;strong&gt;same transaction&lt;/strong&gt; as the insert&lt;/li&gt;
&lt;li&gt;If the insert fails because the ID already exists → the event was already processed → return &lt;code&gt;200&lt;/code&gt; and move on&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Handlers built this way can be retried forever without corrupting state. Handlers built on hope eventually double-create an issue, double-trigger a deploy, or double-charge someone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Replay Deliveries from GitHub's UI
&lt;/h2&gt;

&lt;p&gt;Most developers never notice the &lt;strong&gt;Recent Deliveries&lt;/strong&gt; tab in the webhook settings page. It shows every delivery GitHub attempted — the full request and response — plus a &lt;strong&gt;Redeliver&lt;/strong&gt; button.&lt;/p&gt;

&lt;p&gt;Combined with your tunnel's request inspector, this is the fastest debug loop available:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick any past delivery&lt;/li&gt;
&lt;li&gt;Hit &lt;strong&gt;Redeliver&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Watch it arrive in the inspector&lt;/li&gt;
&lt;li&gt;See exactly how your handler responded&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No fake commits, no dummy pull requests, no waiting for real events to trigger the code path you're testing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Test the Failure Paths on Purpose
&lt;/h2&gt;

&lt;p&gt;Happy-path testing tells you the integration works on a good day. Before calling it done:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Return a &lt;code&gt;500&lt;/code&gt; and watch GitHub's retry behavior&lt;/li&gt;
&lt;li&gt;Kill your server mid-delivery&lt;/li&gt;
&lt;li&gt;Redeliver the same event twice and confirm the second attempt is a harmless no-op&lt;/li&gt;
&lt;li&gt;Disconnect the tunnel, reconnect, and confirm nothing needs manual repair&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these takes two minutes. Together they replace the 2 AM incident where you learn all of it at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The complete setup:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What it solves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Stable public URL&lt;/td&gt;
&lt;td&gt;Webhook config doesn't break on restart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Raw-body signature verification&lt;/td&gt;
&lt;td&gt;Confirms events actually came from GitHub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Idempotent handlers (dedupe on delivery ID)&lt;/td&gt;
&lt;td&gt;Prevents double-processing from retries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Replay-driven debugging&lt;/td&gt;
&lt;td&gt;Fast iteration without fake test data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5. Deliberate chaos testing&lt;/td&gt;
&lt;td&gt;Surfaces failure modes before production does&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Do these five and moving to production is boring — same handler, same verification, just a new URL and secret in the settings.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full guide with working verification code in Node, Python, and Rust, plus the retry semantics nobody documents:&lt;/em&gt; &lt;a href="https://21tunnel.com/blog/test-github-webhooks-locally/" rel="noopener noreferrer"&gt;21tunnel-blog&lt;/a&gt; &lt;/p&gt;

</description>
      <category>rust</category>
      <category>webdev</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Giving AI coding agents a public URL — the master-key delegation pattern</title>
      <dc:creator>gokul</dc:creator>
      <pubDate>Wed, 05 Aug 2026 11:55:09 +0000</pubDate>
      <link>https://dev.to/gokulnh/giving-ai-coding-agents-a-public-url-the-master-key-delegation-pattern-2jj3</link>
      <guid>https://dev.to/gokulnh/giving-ai-coding-agents-a-public-url-the-master-key-delegation-pattern-2jj3</guid>
      <description>&lt;p&gt;Your AI coding agent needs a public URL for the preview it just built. Most setup guides tell you to paste your API token into the agent's environment. That token can open unlimited tunnels, never expires, and could be exposed if the agent leaks it. There is a safer pattern that has been used across the industry for years.&lt;/p&gt;

&lt;p&gt;Full disclosure: I work with &lt;a href="https://21tunnel.com/" rel="noopener noreferrer"&gt;21tunnel&lt;/a&gt;, one of the tunneling services that implements this pattern. While the examples use 21tunnel, the security model applies to any tunneling service.&lt;/p&gt;

&lt;p&gt;AI coding agents like Claude Code, Cursor, Aider, and Devin increasingly need to expose localhost so developers can test webhooks, preview applications, or share work with teammates. The real challenge isn't tunneling—it's securely delegating permissions to an autonomous agent.&lt;/p&gt;

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

&lt;p&gt;Don't give your AI agent a permanent API token.&lt;/p&gt;

&lt;p&gt;Instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a master key.&lt;/li&gt;
&lt;li&gt;Let the master key mint scoped, short-lived child keys.&lt;/li&gt;
&lt;li&gt;The AI agent only receives a temporary child key.&lt;/li&gt;
&lt;li&gt;Child keys expire automatically.&lt;/li&gt;
&lt;li&gt;If needed, revoke the master key to immediately invalidate every child key it created.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This follows the same security model used by Stripe Restricted Keys, GitHub Fine-Grained Personal Access Tokens, AWS STS, and HashiCorp Vault.&lt;/p&gt;

&lt;p&gt;The Problem&lt;/p&gt;

&lt;p&gt;Imagine asking Claude Code to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a Next.js application.&lt;/li&gt;
&lt;li&gt;Configure a Stripe webhook.&lt;/li&gt;
&lt;li&gt;Start the development server.&lt;/li&gt;
&lt;li&gt;Expose localhost.&lt;/li&gt;
&lt;li&gt;Share the public URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent needs credentials to create the tunnel.&lt;/p&gt;

&lt;p&gt;There are three approaches:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Give the agent your personal API token.&lt;/li&gt;
&lt;li&gt;Create another account for the agent.&lt;/li&gt;
&lt;li&gt;Give the agent permission to mint temporary credentials.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first two provide broad, long-lived permissions.&lt;/p&gt;

&lt;p&gt;The third provides only the permissions required for the current task.&lt;/p&gt;

&lt;p&gt;Why Permanent Tokens Are Risky&lt;/p&gt;

&lt;p&gt;Using a permanent API token introduces several problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The token may appear in logs or chat transcripts.&lt;/li&gt;
&lt;li&gt;The token never expires.&lt;/li&gt;
&lt;li&gt;Revoking it affects every application using it.&lt;/li&gt;
&lt;li&gt;The token usually has more permissions than the task requires.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI agent only needs to create one temporary tunnel—not full account access.&lt;/p&gt;

&lt;p&gt;The Master-Key Pattern&lt;/p&gt;

&lt;p&gt;The pattern uses three components.&lt;/p&gt;

&lt;p&gt;Master Key&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Long-lived&lt;/li&gt;
&lt;li&gt;Can mint child keys&lt;/li&gt;
&lt;li&gt;Cannot directly create or manage tunnels&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Child Key&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Short-lived&lt;/li&gt;
&lt;li&gt;Limited to one project&lt;/li&gt;
&lt;li&gt;Automatically expires&lt;/li&gt;
&lt;li&gt;Cannot mint additional keys&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cascade Revoke&lt;/p&gt;

&lt;p&gt;Revoking the master key immediately invalidates every child key created from it.&lt;/p&gt;

&lt;p&gt;Benefits&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Short-lived credentials&lt;/li&gt;
&lt;li&gt;Project isolation&lt;/li&gt;
&lt;li&gt;Easy compromise recovery&lt;/li&gt;
&lt;li&gt;Reduced blast radius&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Industry Examples&lt;/p&gt;

&lt;p&gt;This isn't a new idea.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stripe Restricted API Keys&lt;/li&gt;
&lt;li&gt;GitHub Fine-Grained Personal Access Tokens&lt;/li&gt;
&lt;li&gt;AWS STS AssumeRole&lt;/li&gt;
&lt;li&gt;OpenAI Service Accounts&lt;/li&gt;
&lt;li&gt;HashiCorp Vault Dynamic Secrets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All follow the same principle:&lt;/p&gt;

&lt;p&gt;Long-lived credentials should only create short-lived credentials.&lt;/p&gt;

&lt;p&gt;Creating a Project&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mytunnel projects create staging-preview
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Project created: proj_8f3a2b

Master key:
mtk_master_a1b2c3...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the master key securely.&lt;/p&gt;

&lt;p&gt;Minting a Child Key&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;MTK_MASTER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mtk_master_xxxxx &lt;span class="se"&gt;\&lt;/span&gt;
mytunnel &lt;span class="nb"&gt;eval &lt;/span&gt;mint &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--project&lt;/span&gt; staging-preview &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--ttl&lt;/span&gt; 1h &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--output-env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example output:&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;MYTUNNEL_AUTH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mtk_child_xxxxx
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;MYTUNNEL_PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;staging-preview
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The child key:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Works only within one project&lt;/li&gt;
&lt;li&gt;Cannot mint more keys&lt;/li&gt;
&lt;li&gt;Automatically expires&lt;/li&gt;
&lt;li&gt;Appears in the audit log&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Revoking Everything&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mytunnel keys revoke &lt;span class="nt"&gt;--cascade&lt;/span&gt; mtk_master_xxxxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Revoked 1 master key.

Revoked 17 child keys.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example Configuration&lt;/p&gt;

&lt;p&gt;Claude Code&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.claude/settings.local.json&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env"&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;"MTK_MASTER"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mtk_master_xxxxx"&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;Agent instructions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;When localhost needs to be exposed:

1. Mint a temporary child key.
2. Start the tunnel.
3. Return the public URL.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cursor&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store the master key in a gitignored &lt;code&gt;.env&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add rules instructing the agent to mint a child key before opening a tunnel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Aider&lt;/p&gt;

&lt;p&gt;Store the master key in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.aider.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aider &lt;span class="nt"&gt;--env-file&lt;/span&gt; .aider.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Devin&lt;/p&gt;

&lt;p&gt;Store the master key as a workspace secret.&lt;/p&gt;

&lt;p&gt;Each workspace receives temporary credentials without exposing permanent account access.&lt;/p&gt;

&lt;p&gt;Design Decisions&lt;/p&gt;

&lt;p&gt;The implementation intentionally avoids:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Agent-specific SDKs&lt;/li&gt;
&lt;li&gt;MCP servers for tunnel management&lt;/li&gt;
&lt;li&gt;Additional machine-key types&lt;/li&gt;
&lt;li&gt;IP allowlists for temporary credentials&lt;/li&gt;
&lt;li&gt;OAuth-based agent authentication&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simple CLI and temporary credentials provide a smaller attack surface while remaining compatible with every AI coding agent.&lt;/p&gt;

&lt;p&gt;What Every Tunneling Service Should Provide&lt;/p&gt;

&lt;p&gt;A secure delegation system should include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A master key that cannot directly create tunnels.&lt;/li&gt;
&lt;li&gt;Child keys with enforced expiration.&lt;/li&gt;
&lt;li&gt;One-step cascade revocation.&lt;/li&gt;
&lt;li&gt;Project-level isolation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As AI agents become part of everyday development workflows, developers will increasingly expect temporary, scoped credentials instead of permanent API tokens.&lt;/p&gt;

&lt;p&gt;FAQ&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not give the AI agent my normal API token?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because permanent tokens provide broad permissions, never expire, and are difficult to revoke selectively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is cascade revoke?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A single action that invalidates the master key and every child key created from it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can this pattern work without &lt;a href="https://21tunnel.com/" rel="noopener noreferrer"&gt;21tunnel&lt;/a&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes.&lt;/p&gt;

&lt;p&gt;Any tunneling service can implement scoped temporary credentials. The examples here simply demonstrate one implementation.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>10 ngrok alternatives in 2026 — tested, ranked, honestly</title>
      <dc:creator>gokul</dc:creator>
      <pubDate>Tue, 04 Aug 2026 09:51:05 +0000</pubDate>
      <link>https://dev.to/gokulnh/10-ngrok-alternatives-in-2026-tested-ranked-honestly-35eg</link>
      <guid>https://dev.to/gokulnh/10-ngrok-alternatives-in-2026-tested-ranked-honestly-35eg</guid>
      <description>&lt;p&gt;Every credible ngrok alternative in 2026, tested and scored on a reproducible 8-dimension rubric — setup, stable URLs, custom domains, self-hosting, AI-agent delegation, pricing, inspector, polish.&lt;/p&gt;

&lt;p&gt;I maintain one of the tools on this list, so let me get that out of the way up front: yes, &lt;a href="https://21tunnel.com/" rel="noopener noreferrer"&gt;21tunnel&lt;/a&gt; is ranked #1 and yes, I'm biased — every competitor was scored on the same reproducible rubric, and I link the scoring method so you can rerun it yourself.&lt;/p&gt;

&lt;p&gt;I got here the honest way: my ngrok free tier ran out mid-demo, I rage-tested every alternative I could find, and the notes turned into a benchmark. Here's what actually matters when you pick a tunnel in 2026 — and where each tool genuinely wins.&lt;/p&gt;

&lt;p&gt;ngrok is the default tunneling service and has been for a decade. That's worth something. But "default" isn't "best for you" — the free-tier subdomain churn breaks webhook integrations, the paid tier starts at ~$10/mo for a single reserved domain, the server is closed-source SaaS-only, and the AI-agent delegation story is still your-personal-token-in-an-env-var. Below: every credible ngrok alternative in 2026, scored on a reproducible 8-dimension rubric, with honest verdicts on which one fits which job.&lt;/p&gt;

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

&lt;p&gt;Quick verdict by use case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Default + polish — ngrok still wins on documentation, dashboard polish, and the size of the existing Stack Overflow / Reddit answer pool.&lt;/li&gt;
&lt;li&gt;Free + custom domain — Cloudflare Tunnel, if your domain is on Cloudflare DNS.&lt;/li&gt;
&lt;li&gt;Simplest setup, no install — Pinggy.io, one SSH command.&lt;/li&gt;
&lt;li&gt;Self-host the whole stack — 21tunnel (Rust, MIT + Apache-2.0), frp (Go, Apache-2.0), or bore (Rust, MIT).&lt;/li&gt;
&lt;li&gt;Building for AI coding agents — 21tunnel (master key + scoped child keys + cascade revoke). Every other alternative leaks your full-access token to the agent.&lt;/li&gt;
&lt;li&gt;Already on Tailscale — Tailscale Funnel is built into your existing mesh, no second tool needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How we scored&lt;/p&gt;

&lt;p&gt;Eight dimensions, each scored 0–2, then summed for a 0–16 composite. We score on what's in each product's documented free tier (or its lowest paid tier if there's no free tier) as of 2026-05-11. Pricing accurate on the date of writing; the SaaS pricing pages drift, so the absolute numbers age — the relative ranking shouldn't change much.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Setup friction — 0 = needs account + auth token before first run. 2 = single command, no account.&lt;/li&gt;
&lt;li&gt;Stable URL on free tier — does the URL survive a tunnel restart? Webhook integrations depend on this. 0 = no. 2 = yes, including a reserved or custom subdomain.&lt;/li&gt;
&lt;li&gt;Custom domain on free tier — bring your own domain at $0. 0 = paid only. 2 = free.&lt;/li&gt;
&lt;li&gt;Self-hostability — can you run the server-side yourself? 0 = no, SaaS only. 2 = open-source server you can run on your own VM.&lt;/li&gt;
&lt;li&gt;AI-agent delegation — does it have a master-key pattern so an AI agent can mint short-lived scoped child keys without holding your god-mode token? 0 = no. 2 = first-class, with cascade revoke.&lt;/li&gt;
&lt;li&gt;Pricing transparency — does the public pricing page tell you the full cost of the use case you're shopping for? 0 = "contact us". 2 = simple table.&lt;/li&gt;
&lt;li&gt;Request inspector / audit log — can you see and replay what came through? 0 = no. 2 = yes, on free or lowest paid tier.&lt;/li&gt;
&lt;li&gt;Polish + ecosystem — docs, dashboard, editor extensions, community size. 0 = barely functional. 2 = best-in-class.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We do not score on "is the company old" or "do investors like it" — those don't matter for the decision you're making. We also don't score raw throughput because every tunneling service is fast enough for development. Sustained production traffic is a different question (none of these are built for that).&lt;/p&gt;

&lt;p&gt;The 10 alternatives, ranked&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;21tunnel — 15/16&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: developers building AI-agent workflows, anyone who wants to self-host the entire stack on their own VM, and developers who want a custom domain on a free tier without being locked into Cloudflare DNS.&lt;/p&gt;

&lt;p&gt;21tunnel ships an open-source Rust server (TLS 1.3 + yamux multiplexing), an open-source CLI agent (&lt;code&gt;mytunnel&lt;/code&gt;), and a hosted dashboard. Free Hobby tier includes 3 concurrent tunnels, 10 Mbps per tunnel, 20,000 requests/month, and custom domain support on signup. Pro is $10/mo flat.&lt;/p&gt;

&lt;p&gt;The differentiator is the AI-agent delegation primitive: a &lt;code&gt;mtk_master_&lt;/code&gt; key can mint scoped child keys with TTL, organized into project namespaces with subdomain isolation, with one-click cascade revoke. Same shape Stripe / OpenAI / Resend use for service accounts, applied to tunnels.&lt;/p&gt;

&lt;p&gt;Scoring: setup 1 (account needed) · stable URL 2 · custom domain free 2 · self-host 2 · AI-agent delegation 2 · pricing 2 · inspector 2 · polish 2.&lt;/p&gt;

&lt;p&gt;Caveat: we made it. Smaller community than ngrok — the Stack Overflow answer pool is thinner. We made up for it with explicit docs but you'll occasionally have to read source.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;ngrok — 12/16&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: people who want the safe default, have heard of ngrok, and don't need the cheapest or most open option.&lt;/p&gt;

&lt;p&gt;ngrok is the category leader and has been since 2015. The agent is open-source on GitHub; the server is proprietary SaaS. Free tier gives you one tunnel with an ephemeral subdomain — fine for a one-off, painful for daily webhook development because the URL changes every restart. Personal ~$10/mo gets you a reserved your-name.ngrok.io. Pro ~$20/mo adds custom domains and multiple concurrent tunnels. Documentation is genuinely excellent — the docs site is one of the best in the developer-tools space.&lt;/p&gt;

&lt;p&gt;Scoring: setup 1 · stable URL 0 (free ephemeral) · custom domain free 0 · self-host 0 · AI-agent delegation 1 (auth tokens exist but no scoped child keys) · pricing 2 · inspector 2 · polish 2.&lt;/p&gt;

&lt;p&gt;Honest: if you've used ngrok for years and you're happy, the migration cost to anything else isn't worth the savings. Switch when you hit a specific pain point — webhook URL churn, custom-domain pricing, or AI-agent delegation needs.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cloudflare Tunnel — 12/16&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: developers whose domains are already on Cloudflare DNS. Genuinely free for the use case most people care about (one custom-domain tunnel).&lt;/p&gt;

&lt;p&gt;Cloudflare Tunnel (formerly Argo Tunnel) is part of Cloudflare's Zero Trust suite. You run &lt;code&gt;cloudflared&lt;/code&gt; on your machine, it opens an outbound connection to Cloudflare's edge, traffic for your domain (which must be on Cloudflare DNS) rides through to localhost. Custom domain, free TLS via Cloudflare, all included. Optional Cloudflare Access for auth (free up to 50 users). The setup is heavier than ngrok — you're configuring a DNS record and a cloudflared service — but once it's running, it's just there.&lt;/p&gt;

&lt;p&gt;Scoring: setup 1 (requires CF account and DNS setup) · stable URL 2 · custom domain free 2 · self-host 0 (Cloudflare edge is proprietary) · AI-agent delegation 1 (service tokens exist, no first-class child-key mint) · pricing 2 · inspector 0 · polish 2.&lt;/p&gt;

&lt;p&gt;Honest: if your domain isn't on Cloudflare DNS, the migration to get this working might cost you more than just paying ngrok or 21tunnel for a year.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pinggy.io — 10/16&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: the absolutely-zero-install case — you just want to run one SSH command, get a URL, share it.&lt;/p&gt;

&lt;p&gt;Pinggy is a small, focused tunneling service with an elegant pitch: SSH is already installed everywhere, so use SSH as the agent. &lt;code&gt;ssh -p 443 -R 0:localhost:3000 a.pinggy.io&lt;/code&gt; and you get back a HTTPS URL. No binary to install, no account for the free tier, works on Windows / macOS / Linux uniformly. Paid tier ~$3/mo adds reserved subdomains and longer session timeouts.&lt;/p&gt;

&lt;p&gt;Scoring: setup 2 (one SSH command) · stable URL 0 (free tier session expires hourly) · custom domain free 0 · self-host 0 · AI-agent delegation 0 · pricing 2 · inspector 1 (web inspector on paid tier) · polish 1.&lt;/p&gt;

&lt;p&gt;Honest: the cheapest paid tier in the category. If "I just need a URL right now" is your only requirement, Pinggy is the lightest tool here.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Localtunnel — 8/16&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: indie / hobby projects, npm users who want a free tunnel without an account.&lt;/p&gt;

&lt;p&gt;Localtunnel is an npm package (&lt;code&gt;npx localtunnel port 3000&lt;/code&gt;) that gives you a &lt;code&gt;*.loca.lt&lt;/code&gt; subdomain forwarding to localhost. Free, no account. It's maintained by volunteers, uptime is best-effort, and it occasionally goes down for hours. Don't build a webhook-critical workflow on it; do use it for a quick share.&lt;/p&gt;

&lt;p&gt;Scoring: setup 2 (one npx command) · stable URL 0 · custom domain free 0 · self-host 1 (the server is open-source on GitHub but running it is fiddly) · AI-agent delegation 0 · pricing 2 (free) · inspector 0 · polish 1.&lt;/p&gt;

&lt;p&gt;Honest: indie spirit, modest reliability. Treat it as a community amenity, not infrastructure.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tailscale Funnel — 10/16&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: teams already on Tailscale who want to expose one localhost service to the public internet without adding a second tool.&lt;/p&gt;

&lt;p&gt;Tailscale Funnel is Tailscale's "expose a service from your tailnet to the public internet" feature. If you already run Tailscale, Funnel lets you mark a service public with one command — no new binary, no new dashboard. It rides Tailscale's existing auth (SSO, ACLs, device posture).&lt;/p&gt;

&lt;p&gt;Scoring: setup 1 (already-on-Tailscale prerequisite) · stable URL 2 (uses your tailnet hostnames) · custom domain free 1 (limited) · self-host 0 · AI-agent delegation 1 · pricing 2 · inspector 0 · polish 2.&lt;/p&gt;

&lt;p&gt;Honest: if you're not on Tailscale, the cost of adopting Tailscale just to use Funnel is way more friction than installing ngrok. Don't pick this as your first tunnel tool.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;bore — 8/16&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: minimalists who want the simplest possible self-hostable Rust binary.&lt;/p&gt;

&lt;p&gt;bore is a single Rust binary that does one thing: TCP tunneling. Run &lt;code&gt;bore server&lt;/code&gt; on a VPS, &lt;code&gt;bore local 3000 to your-server.com&lt;/code&gt; on your laptop, done. No HTTPS termination, no dashboard, no web inspector — that's the point. You bring your own TLS via Caddy in front. MIT-licensed, tiny codebase (~1,500 lines), genuinely simple.&lt;/p&gt;

&lt;p&gt;Scoring: setup 1 (need a VPS) · stable URL 2 · custom domain free 2 (you set up DNS) · self-host 2 · AI-agent delegation 0 · pricing 2 (free + VPS cost) · inspector 0 · polish 1.&lt;/p&gt;

&lt;p&gt;Honest: if you want a tunnel and you're comfortable running a server, bore is the right pick for the philosophical-minimalist case. Doesn't try to be a product.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;serveo — 7/16&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: quick one-offs over SSH; no install on most platforms.&lt;/p&gt;

&lt;p&gt;serveo is an SSH-based tunnel service. &lt;code&gt;ssh -R 80:localhost:3000 serveo.net&lt;/code&gt; and you get a &lt;code&gt;https://*.serveo.net&lt;/code&gt; URL. No account, no install. It's run by an independent operator and goes down occasionally; reliability is similar to Localtunnel.&lt;/p&gt;

&lt;p&gt;Scoring: setup 2 · stable URL 1 (best-effort subdomain) · custom domain free 0 · self-host 0 · AI-agent delegation 0 · pricing 2 (free) · inspector 0 · polish 1.&lt;/p&gt;

&lt;p&gt;Honest: historically reliable, but single-operator means single point of failure. Fine as a backup; not infrastructure.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;localhost.run — 7/16&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: developers who like serveo's shape but want a more actively-maintained alternative.&lt;/p&gt;

&lt;p&gt;localhost.run is essentially serveo's spiritual sibling — SSH-based tunneling, no install, no account for the free tier. Free tier gives you a &lt;code&gt;*.lhr.life&lt;/code&gt; URL with hourly rotation; paid tier ~$5/mo gives you reserved subdomains and custom domains.&lt;/p&gt;

&lt;p&gt;Scoring: setup 2 · stable URL 0 (free) · custom domain free 0 · self-host 0 · AI-agent delegation 0 · pricing 2 · inspector 1 (paid tier) · polish 2.&lt;/p&gt;

&lt;p&gt;Honest: nicer dashboard than serveo, paid tier is one of the cheapest in the category. Picks up where serveo leaves off.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;frp — 9/16&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: self-host purists who want the most flexibility.&lt;/p&gt;

&lt;p&gt;frp (Fast Reverse Proxy) is a Go-based self-hostable tunneling stack. Run &lt;code&gt;frps&lt;/code&gt; on a server, &lt;code&gt;frpc&lt;/code&gt; on your machine, configure TOML, done. Supports TCP / UDP / HTTPS / STCP / XTCP — the most protocol variety on this list. Apache-2.0 licensed. Documentation is partially Chinese-first; the English docs are good but thinner.&lt;/p&gt;

&lt;p&gt;Scoring: setup 0 (TOML config) · stable URL 2 · custom domain free 2 · self-host 2 · AI-agent delegation 0 · pricing 2 · inspector 0 · polish 1.&lt;/p&gt;

&lt;p&gt;Honest: if you want maximum flexibility and don't mind a steeper learning curve, frp is the most configurable thing on this list. If you want a product, not a toolkit, look elsewhere.&lt;/p&gt;

&lt;p&gt;How to pick&lt;/p&gt;

&lt;p&gt;If you want the default and don't want to think about it: ngrok. The docs are excellent, the Stack Overflow answer pool is the largest, and the primitive (a tunnel from a public URL to localhost) is the same everywhere.&lt;/p&gt;

&lt;p&gt;If your domain is already on Cloudflare DNS: Cloudflare Tunnel. Free, custom domain, integrates with Cloudflare Access for auth. Setup is heavier than ngrok's but it's a one-time cost.&lt;/p&gt;

&lt;p&gt;If you need the cheapest possible paid tier: Pinggy at ~$3/mo or localhost.run at ~$5/mo. Both work over SSH; the differentiator is dashboard polish.&lt;/p&gt;

&lt;p&gt;If you want to self-host the entire stack: 21tunnel (if you also want a dashboard and a product), frp (if you want maximum protocol variety), or bore (if you want the smallest binary).&lt;/p&gt;

&lt;p&gt;If you're building AI-agent workflows: 21tunnel. The master-key pattern was built specifically for this case — every other tool on this list leaks your god-mode token to the agent.&lt;/p&gt;

&lt;p&gt;If you're already on Tailscale: Funnel. It's already paid for.&lt;/p&gt;

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

&lt;p&gt;What's the best free alternative to ngrok in 2026?&lt;br&gt;
Cloudflare Tunnel if your domain is on Cloudflare DNS (custom domain free, no time limits). Pinggy if you want zero install. 21tunnel's free Hobby tier if you want 3 concurrent tunnels + custom domain + an upgrade path to AI-agent delegation.&lt;/p&gt;

&lt;p&gt;Is there a fully open-source alternative to ngrok?&lt;br&gt;
Yes: 21tunnel (Rust, MIT + Apache-2.0, server + agent + dashboard), bore (Rust, MIT, agent + server), frp (Go, Apache-2.0). ngrok itself open-sources only the agent.&lt;/p&gt;

&lt;p&gt;Why is ngrok so expensive compared to alternatives?&lt;br&gt;
Brand inertia. They were first and they're still the default. Cloudflare Tunnel undercuts on price (free), 21tunnel undercuts on feature-for-feature pricing ($10/mo with custom domain vs ngrok's $20/mo Pro), Pinggy undercuts on absolute price (~$3/mo).&lt;/p&gt;

&lt;p&gt;Can these handle production traffic?&lt;br&gt;
Honestly, no — none of these are designed for sustained production traffic. They're development tools. If you need a public endpoint for a production app, terminate at a normal CDN / load balancer and connect to your real backend.&lt;/p&gt;

&lt;p&gt;Which alternative is best for AI coding agents?&lt;br&gt;
21tunnel, because of the master-key delegation pattern. The agent gets a short-lived scoped child key, not your god-mode token. Cascade revoke from the dashboard nukes every key the agent ever minted in one click. ngrok and Cloudflare Tunnel both work but require giving the agent a higher-privilege token than ideal.&lt;/p&gt;

&lt;p&gt;What about self-hosted ngrok v1?&lt;br&gt;
ngrok's v1 was open-source. v2 onwards is closed. The v1 codebase still exists on GitHub but is unmaintained and missing a decade of fixes. Don't deploy it to production. Use frp or 21tunnel for the self-host case.&lt;/p&gt;

&lt;p&gt;Want to try the open-source alternative we ranked #1? &lt;a href="https://21tunnel.com/" rel="noopener noreferrer"&gt;21tunnel&lt;/a&gt; is free on Hobby (3 tunnels, 10 Mbps per tunnel, 20,000 requests/month, custom domain on signup) and Pro is $10/mo flat. Or run the whole stack on your own VM — dual MIT + Apache-2.0 license, no SaaS dependency.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>opensource</category>
      <category>tooling</category>
    </item>
  </channel>
</rss>
