The request sounded like a one-line job. The teammate who owns the backend handed over an *.ngrok-free.dev URL and asked for the frontend to be connected to it. The backend was Django running locally behind an ngrok tunnel whose address kept rotating. The frontend was the client's React app, built with Create React App on react-scripts 5.0.1, and its axios client is created with a baseURL read from process.env.REACT_APP_BASE_URL. It looked like changing one environment variable would be enough.
It was not, and the way it failed was confusing. On 4 September 2026 this cost real time, even though axios never threw a single error.
A 200 whose body is a warning page
On the free plan, ngrok serves an interstitial warning page to any request whose User-Agent looks like a browser. That page is not limited to navigation. An XHR carrying Sec-Fetch-Mode: cors and Accept: application/json, which says plainly in its own headers that it wants data, still gets it. What decides it is the User-Agent, not the kind of request.
The interstitial response is HTTP 200 with text/plain. Axios in this app is declared with the range ^1.3.4, so it stays in the 1.x line, and by default axios only rejects the promise for statuses outside the 2xx range. The response goes through as a success. There is no error to catch, and the app just receives a page of prose where JSON was expected. The commit message for the fix records exactly that: axios in this app received the warning page instead of JSON.
That is the confusing part. The status that normally reads as a sign that everything is fine is the very thing hiding the problem, and the mistake only shows once you read the body.
The skip header needs the backend's permission
The documented way past that page is the ngrok-skip-browser-warning header. The trap is that a browser cannot send it unless the backend lists it in its CORS configuration.
The reason sits in the CORS rules. A custom header on a cross-origin request makes the browser send a preflight first, an OPTIONS request asking whether that origin and that header are allowed. The real request only goes out if the preflight response allows the origin and names the header in Access-Control-Allow-Headers. An allowed origin on its own is not enough.
The already-allowed origin is exactly what made the setup look ready. The backend already allowed http://localhost:3000 as an origin, and the CORS gap was only visible in the allow-headers list. The commit message for the fix notes that ngrok-skip-browser-warning was not listed in the backend's CORS_ALLOW_HEADERS, so the browser could not send it directly either.
You can confirm this before assuming anything, with one preflight through curl that uses the dev server's origin:
curl -i -X OPTIONS -H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Headers: ngrok-skip-browser-warning" \
https://<tunnel>/v1/ | grep -i access-control-allow-headersIf ngrok-skip-browser-warning is missing from that list, a direct connection from the browser is impossible in both directions. Without the header, the request gets the interstitial. With the header, the preflight is rejected.
The most straightforward route is asking the backend teammate to add the header to CORS_ALLOW_HEADERS. My project notes do list that as the condition before the app could talk to the tunnel without the dev proxy, and the item is still recorded as open. What I chose instead was the dev server's own proxy, not a backend change.
Moving the hop to the tunnel into Node
In Create React App, that kind of proxy lives in src/setupProxy.js. With it, the browser talks to its own origin, so CORS disappears entirely, and the hop to the tunnel happens in Node, where the headers are under your control. The dev server now forwards /v1 and /ws to the address in REACT_APP_API_TARGET and sets the skip header on the Node side. The rule for running the app is to keep REACT_APP_BASE_URL at the relative /v1/, so axios calls are handled by the dev proxy.
Here is an excerpt of the file, simplified. Some lines are merged, the comments are translated, the tunnel URL and the User-Agent value are anonymized, and the guarded require at the top of the file is left out here because it is covered further down.
// src/setupProxy.js, simplified
const TARGET = process.env.REACT_APP_API_TARGET || 'https://<tunnel>'
function applyTunnelHeaders(proxyReq) {
// Skip the ngrok warning page.
proxyReq.setHeader('ngrok-skip-browser-warning', 'true')
// Second seatbelt: a non-browser UA never triggers the warning at all.
proxyReq.setHeader('User-Agent', 'dev-proxy')
}
module.exports = function (app) {
if (!createProxyMiddleware) return
const base = { target: TARGET, changeOrigin: true, secure: true, logLevel: 'warn' }
app.use('/v1', createProxyMiddleware({ ...base, onProxyReq: applyTunnelHeaders }))
app.use('/ws', createProxyMiddleware({ ...base, ws: true, onProxyReqWs: applyTunnelHeaders }))
}According to my notes, the skip header alone or the User-Agent override alone each works. Both are set together anyway, so that a change in how ngrok detects browsers cannot silently break this proxy, and the code comment calls the non-browser UA a second seatbelt.
The default TARGET is the backend teammate's tunnel URL, and REACT_APP_API_TARGET can override it. That helps because a free ngrok tunnel gets a new URL every time it restarts. All of this is development only. Production builds point REACT_APP_BASE_URL at an absolute URL and never load this file.
onProxyReq never runs for a WebSocket
The two routes in that excerpt use different hooks, and the difference is not a matter of style. WebSocket upgrades need onProxyReqWs, not onProxyReq. In http-proxy-middleware v2, the upgrade path emits proxyReqWs and never runs the web pass. A proxy that only sets onProxyReq lets the WebSocket handshake through bare, and the handshake lands on the interstitial with ngrok-error-code: ERR_NGROK_6024. The first version of this proxy got exactly that wrong, and it was caught in review.
The library source shows why. http-proxy emits two events from two different passes, and http-proxy-middleware maps each one to its own option name:
// http-proxy 1.18.1, lib/http-proxy/passes/web-incoming.js
proxyReq.on('socket', function(socket) {
// ...
server.emit('proxyReq', proxyReq, req, res, options);
// http-proxy 1.18.1, lib/http-proxy/passes/ws-incoming.js
// Enable developers to modify the proxyReq before headers are sent
if (server) { server.emit('proxyReqWs', proxyReq, req, socket, options, head); }
// http-proxy-middleware 2.0.7, dist/_handlers.js
proxyReq: 'onProxyReq',
proxyReqWs: 'onProxyReqWs',The proxyReq event is emitted in the web pass, inside the socket handler of the outgoing request, and an upgrade request never goes through that pass. The WebSocket pass has its own event, proxyReqWs, emitted before the headers are sent. A hook attached to the wrong event does not complain about anything. It is simply never called for the handshake.
package-lock.json pins http-proxy-middleware at 2.0.7, including at the fix commit, and the commit message spells out the same reason for using onProxyReqWs. The only commit that touches setupProxy.js already contains the /ws route with ws: true and onProxyReqWs: applyTunnelHeaders, so the wrong version never got a commit of its own in the local git history.
That /ws route only helps if the app actually opens its WebSocket against the dev server rather than straight at the tunnel. In this app, the direction is decided by logic that predates this proxy. The app's WebSocket config uses REACT_APP_WS_BASE_URL when that variable is set. Otherwise it parses REACT_APP_BASE_URL with new URL, and if that fails it uses the protocol and host from window.location. A relative /v1/ always fails to parse with new URL when there is no base, so as long as REACT_APP_WS_BASE_URL is empty, the WebSocket is pointed at the dev server's own host, on a path starting with /ws, the prefix the second proxy route is mounted on. That config file was touched by only two commits, on 25 and 31 August 2026, both before this proxy existed. What has never been recorded as tested is whether that /ws route actually catches the handshake while the app is running.
A dependency that exists by layout accident
The if (!createProxyMiddleware) return line in the excerpt is there for a reason. http-proxy-middleware is not declared in package.json. According to the commit message, it resolves only because webpack-dev-server pulls it in and npm hoists it to the root of node_modules, which is a layout accident rather than a guarantee. The installed webpack-dev-server 4.15.2 does declare http-proxy-middleware ^2.0.3, and the locked 2.0.7 falls inside that range.
So the require is guarded. If the module cannot be found, setupProxy.js prints that the proxy is inactive along with the install command, npm install --save-dev http-proxy-middleware@2, and then exits through that line before a single route is registered.
In the project notes, http-proxy-middleware is still listed as undeclared, resolving only as a hoisted transitive dependency of webpack-dev-server. That decision is one I kept as my own call.
Tunnel errors that are not the frontend's
The proxy only deals with headers and origins. The tunnel behind it stayed fragile, and until 10 September the backend really was a local Django behind a rotating ngrok tunnel that went down often. Its errors have their own shapes. A tunnel that cannot reach the backend answers 502 with ERR_NGROK_8012. When Postgres runs out of connection slots, the answer is a 500 with an OperationalError page. The same list also includes a 504 through the dev proxy. None of those errors comes from the frontend.
On 9 September the tunnel went down four times. Twice it was Postgres running out of connection slots, with FATAL: sorry, too many clients already on port 5438. The other two times the tunnel itself was unregistered, with ERR_NGROK_3200. It went down for good at 14:09 on 10 September, again with ERR_NGROK_3200, and a free ngrok tunnel that is started again comes back with a new URL anyway. Since 14 September the working backend has been staging. Unlike the old tunnel, the staging API stays up, keeps one URL, and needs no ngrok header.
The rule I hold to: read the error body before reporting anything. The Django debug page names the cause exactly.
A 200 is not proof
The proxy commit, chore(dev): proxy api requests to the tunnel backend in development, is timestamped 18:20:04 (+0800) on 4 September 2026, and the project notes list it as a dev-only proxy shipped to the client's branch that day.
The verification recipe is recorded too: curl the proxy with a browser User-Agent and no special headers. Real JSON coming back through localhost:3000 is the proof. A 200 alone is not, because the interstitial is also a 200. What is not recorded is the result of running that recipe. There is no date, no curl output, and the commit message does not mention any testing. So what I can show here is what was installed, not proof that the proxy returns JSON.
If there is one pattern running through all of this, it is a status code that cannot be read on its own. The ngrok interstitial uses 200, so axios treats it as a success. None of the 502, 500 and 504 errors from the tunnel came from the frontend, and for the ones raised by Django, the debug page names the cause exactly. Before wiring a frontend to a free ngrok tunnel, run the preflight curl above and read the access-control-allow-headers list. If the skip header is not there, move the hop into Node. For WebSockets the hook has to be onProxyReqWs, but that is only a precondition, and whether the handshake actually goes through the proxy route is still something to check yourself.