Tutorial
14 min read
18 views

Cookies on Tunnel Domains: Why Login Works on localhost but Breaks on a Tunnel URL

Login works on localhost but fails on your tunnel URL? Learn how Public Suffix List, SameSite, and Secure cookies break sessions, and how to fix them.

IT
InstaTunnel Team
Published by the InstaTunnel team | Editorial policy
Cookies on Tunnel Domains: Why Login Works on localhost but Breaks on a Tunnel URL

Quick answer

Why Login Breaks on Tunnel URLs: Cookie & SameSite Fixes: quick comparison answer

Choose the tunnel tool based on the network model: public HTTPS URLs for webhooks and demos, private mesh access for internal apps, and managed infrastructure when policy controls matter most.

Which tunnel tool is best for public webhook testing?

Use a public HTTPS localhost tunnel with stable URLs. InstaTunnel focuses on webhook testing, demos, OAuth callbacks, and MCP endpoint workflows.

When should I choose a private network tool instead?

Choose a private mesh or Zero Trust tool when every user and service should stay inside a controlled private network.

You build a login flow, test it on localhost:3000, 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.

This is almost always a cookie problem. Browsers give localhost 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, SameSite, Secure), and how to fix it so the setup holds up in development and production.

TL;DR

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

Why localhost behaves differently

localhost is forgiving by design. Three behaviors make local login flows work that would fail on a real domain.

  1. Secure cookies over HTTP. Browsers normally refuse a Secure cookie set from an http: page. MDN documents that this HTTPS requirement is ignored for localhost since Chrome 89 and Firefox 75. Safari is the outlier. It still rejects Secure cookies from plain-HTTP localhost, and WebKit bug 232088 tracks the problem. As of the latest activity on that bug (April 2026), a WebKit change toward supporting Secure 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.
  2. No public suffix involved. localhost is a single-label host, so there is no registrable domain and no shared parent for a cookie to leak into.
  3. Everything is same-site. A frontend on localhost:3000 and an API on localhost:8080 share a site, because ports are ignored.

A tunnel URL such as https://abc123.some-provider.app 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.

Site vs. origin, and why the Public Suffix List matters

An origin is scheme + hostname + port. A site is scheme + the registrable domain (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, http://localhost:3000 and an https:// tunnel URL are never same-site.

Nothing in a domain name says where the registrable domain starts. For example.com it’s obvious. For example.co.uk or yourname.github.io it isn’t. That is what the Public Suffix List (PSL) is for. It is a community-maintained list of suffixes under which the public can register names, such as com, co.uk and pvt.k12.ma.us. 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.

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

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.

Provider Hostname pattern On the PSL?
ngrok (paid) *.ngrok.app, *.ngrok.dev Yes, per ngrok’s docs
ngrok (free) *.ngrok-free.app, *.ngrok-free.dev Yes, per ngrok’s docs
ngrok (legacy) *.ngrok.io Yes; ngrok lists it as discontinued and available only to legacy accounts
Cloudflare quick tunnels random *.trycloudflare.com Yes (private section) according to developers who checked the list; the ruby_native gem also documents that browsers reject cookies scoped to it
Cloudflare named tunnels a hostname on your zone Not a shared suffix; it is your own domain

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 ngrok.app, ngrok-free.app, ngrok.dev, ngrok-free.dev and ngrok.pizza appear in a 2023 PSL release. ngrok’s docs currently mark ngrok-free.pizza as “coming soon” and not yet on the list. Check the current list yourself rather than assuming, because providers add domains over time.

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

Failure #1: The Domain attribute

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

The ruby_native gem documents this for Rails. Apps configured with domain: :all, tld_length: 2 resolve to .trycloudflare.com 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 domain= attribute so the cookie scopes to the exact tunnel hostname.

That’s good general advice. Omitting Domain produces a host-only cookie, sent only to that exact host and not its subdomains. It also limits the damage if a sibling subdomain is compromised.

# 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

Don’t hardcode localhost or guess the domain from a TLD length. Read it from an environment variable and leave it unset in development.

Failure #2: SameSite and cross-site requests

SameSite controls whether a cookie is sent on cross-site requests. The values are Strict, Lax and None.

With the Chrome 80 release in February 2020, Chromium began treating cookies without a SameSite attribute as Lax. Cookies meant for cross-site contexts must specify SameSite=None; Secure, 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, set SameSite explicitly on every cookie.

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 SameSite=Lax gets no grace period, so don’t build on it.

Where does this bite on a tunnel?

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

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

If you truly need cross-site cookies, use SameSite=None; Secure. Chromium-based browsers reject SameSite=None cookies that omit Secure. 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.

Failure #3: Secure cookies behind a TLS-terminating proxy

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.

Some session libraries refuse to set a Secure cookie when they believe the connection is insecure. express-session with cookie.secure: true is a well-known example. Locally you never notice, because localhost 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.

Tell your app to trust the forwarded protocol header (X-Forwarded-Proto) from the tunnel.

  • Express: app.set('trust proxy', 1). Once trust is enabled, req.protocol reflects X-Forwarded-Proto, and req.secure follows it. A number means “trust this many hops from the front-facing proxy”, so adjust it to your actual topology. If you use true, the last trusted proxy must overwrite X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto, or a client can supply any value.
  • Django: SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https'). 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.
  • Rails: allow the tunnel host. Rails reads extra development hosts from the RAILS_DEVELOPMENT_HOSTS environment variable (Spree’s docs suggest RAILS_DEVELOPMENT_HOSTS=.trycloudflare.com for quick tunnels), or you can add the host to config.hosts. Rails also has config.assume_ssl for apps behind a TLS-terminating proxy.
  • Vite: Cloudflare’s docs say to add .trycloudflare.com to preview.allowedHosts for quick tunnels and your own domain suffix for named tunnels. vite dev has an equivalent allowed-hosts setting on the dev server.

Only trust forwarding headers from a proxy you control. In production, restrict trust to known proxy addresses.

Hardening: cookie prefixes and cookie tossing

While you’re fixing attributes, adopt prefixes so the browser enforces your intent.

  • __Secure- requires the Secure attribute and a secure origin.
  • __Host- additionally requires Path=/ and forbids Domain, binding the cookie to exactly one host. Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax

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

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

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

Embedded scenarios: CHIPS

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

Set-Cookie: __Host-widget=34d8g; SameSite=None; Secure; Path=/; Partitioned

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.

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 SameSite=None cookies as unreliable. For federated sign-in flows, FedCM is the retained API worth evaluating.

A debugging checklist

When login works locally and fails through a tunnel, go in this order.

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

Why stable URLs and custom subdomains solve most of this

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.

How much this affects you depends on the provider. Cloudflare quick tunnels assign a new random *.trycloudflare.com hostname each time cloudflared 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.

A stable, reserved hostname fixes that whole class of problems.

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

If you need several subdomains sharing a session (say app. and api.), 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 Domain=.yourdomain.com works as expected.

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.

Layered authentication and cookies

If a tunnel sits behind a password gate or access policy, you now have two authentication layers. HTTP Basic Auth sends credentials in the Authorization 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.

FAQ

Why does my login work on localhost but not on my ngrok or Cloudflare URL?
Chrome and Firefox exempt localhost from the HTTPS requirement for Secure cookies, and localhost 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.

Is *.ngrok-free.app on the Public Suffix List?
Yes. ngrok states its managed base domains are on the list, which is why subdomains can’t read each other’s cookies.

Is *.trycloudflare.com on the Public Suffix List?
Developers who checked the list report that it is, and the ruby_native gem documents that browsers reject cookies scoped to it. Confirm against the current list at publicsuffix.org.

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

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

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

Conclusion

When login breaks on a tunnel, the tunnel is rarely at fault. The browser is applying real-world rules that localhost quietly exempts you from: public suffix boundaries, SameSite defaults and Secure requirements. Use host-only __Host- 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.

Sources

Continue from this article into the most relevant product guides and workflows.

Related Topics

#cookies on tunnel domains#localhost tunnel cookies#login not working on tunnel URL#session lost behind tunnel#cookies not set on ngrok#cookies not working on tunnel#tunnel URL login redirect loop#SameSite cookie tunnel#SameSite None Secure#SameSite Lax vs None#SameSite Strict problems#Secure cookie localhost#Secure cookie HTTPS tunnel#Public Suffix List#Public Suffix List cookies#public suffix tunnel domains#cross-subdomain cookies#cookie domain attribute#cookie Domain vs Host-only#__Host- cookie prefix#__Secure- cookie prefix#third-party cookies blocked#cross-site cookies#cookie partitioning CHIPS#HttpOnly cookie#session cookie debugging#debug cookies in Chrome DevTools#Set-Cookie header not working#cookie rejected by browser#cookie blocked SameSite warning#localhost vs HTTPS cookies#localhost secure context exception#OAuth callback tunnel#OAuth redirect URI tunnel#OAuth login localhost tunnel#webhook testing tunnel#CSRF token cookie tunnel#JWT cookie tunnel#session fixation tunnel#trust proxy Express cookies#X-Forwarded-Proto cookies#reverse proxy cookie issues#Django CSRF trusted origins tunnel#Rails session cookie tunnel#Next.js auth cookies tunnel#NextAuth localhost tunnel#Laravel session cookie tunnel#localhost tunneling#expose localhost to internet#localhost tunneling tools#ngrok alternatives#Cloudflare Tunnel cookies#custom subdomain tunnel#stable tunnel URL#password protected tunnel#HTTPS tunnel for local development#local development authentication#web developer debugging tips#developer infrastructure#InstaTunnel

Keep building with InstaTunnel

Read the docs for implementation details or compare plans before you ship.

Share this article

More InstaTunnel Insights

Discover more tutorials, tips, and updates to help you build better with localhost tunneling.

Browse All Articles