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 onlocalhostis 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
SameSiteattribute asLax, 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
Securecookies 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.
-
Secure cookies over HTTP. Browsers normally refuse a
Securecookie set from anhttp:page. MDN documents that this HTTPS requirement is ignored forlocalhostsince Chrome 89 and Firefox 75. Safari is the outlier. It still rejectsSecurecookies from plain-HTTPlocalhost, and WebKit bug 232088 tracks the problem. As of the latest activity on that bug (April 2026), a WebKit change toward supportingSecurecookies 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. -
No public suffix involved.
localhostis a single-label host, so there is no registrable domain and no shared parent for a cookie to leak into. -
Everything is same-site. A frontend on
localhost:3000and an API onlocalhost:8080share 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
Laxsession cookie won’t be attached tofetchcalls between them. -
OAuth and SSO redirects. A top-level GET back to your callback carries
Laxcookies. 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
LaxandStrictcookies.
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.protocolreflectsX-Forwarded-Proto, andreq.securefollows it. A number means “trust this many hops from the front-facing proxy”, so adjust it to your actual topology. If you usetrue, the last trusted proxy must overwriteX-Forwarded-For,X-Forwarded-HostandX-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_HOSTSenvironment variable (Spree’s docs suggestRAILS_DEVELOPMENT_HOSTS=.trycloudflare.comfor quick tunnels), or you can add the host toconfig.hosts. Rails also hasconfig.assume_sslfor apps behind a TLS-terminating proxy. -
Vite: Cloudflare’s docs say to add
.trycloudflare.comtopreview.allowedHostsfor quick tunnels and your own domain suffix for named tunnels.vite devhas 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 theSecureattribute and a secure origin. -
__Host-additionally requiresPath=/and forbidsDomain, 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.
-
Network tab: inspect the login response’s
Set-Cookie. Chrome’s DevTools warn about cookies affected bySameSiterules and show attributes for each request. -
Application → Cookies: is the cookie stored at all? If not, the browser rejected it (bad
Domain,Secureover HTTP, orSameSite=NonewithoutSecure). -
Next request: stored but not sent usually means
SameSiteand a cross-site request. Check the request’sSec-Fetch-Siteheader, which browsers send assame-origin,same-site,cross-siteornoneand which page scripts can’t modify. It is supported in Chrome 76+, Firefox 90+ and Safari 16.4+. - Hostnames: are the frontend, API and callback URLs all the same host?
-
Command line:
curl -i https://your-tunnel-url/loginshows the rawSet-Cookieheaders your app emits. -
Redirects: hardcoded
http://localhost:3000redirects send the browser back to localhost mid-flow. -
Proxy trust: confirm your framework sees HTTPS through
X-Forwarded-Proto. - 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/callbackonce 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
SameSiteentirely.
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
- Public Suffix List and GitHub repository
- Cookies and the Public Suffix List (Heroku)
- ngrok Domains docs, ngrok domains and TCP addresses and ngrok’s PSL explainer
- Same-site and same-origin (web.dev)
- SameSite FAQ (Chromium) and SameSite cookie changes in February 2020 (Chromium Blog)
- Set-Cookie (MDN)
- Cookie not being set in Safari and WebKit Bugzilla 232088
- Update on plans for Privacy Sandbox technologies (Google, October 2025)
- CHIPS (Privacy Sandbox)
- RubyNative::TunnelCookieMiddleware
- Express behind proxies
- Django settings: SECURE_PROXY_SSL_HEADER
- Spree: wallet payments in development (Rails tunnel hosts)
- Cloudflare: share a local dev server
Originally published at https://instatunnel.my/blog/cookies-on-tunnel-domains-why-login-works-on-localhost-but-breaks-on-a-tunnel-url
Top comments (0)