OneRuby.devAN ENGINEERING NOTEBOOK

AI · 5 min read

ngrok With Local LLMs: Test Authentication Before Sharing Ollama

Test the HTTP authentication boundary before sharing Ollama or LM Studio through ngrok, with a runnable local preflight and explicit integration limits.

A local model answering through a public URL proves that routing works. It does not prove that the next caller needs credentials. Before sharing an Ollama or LM Studio endpoint through ngrok, the first useful request is one that should fail.

This note builds a small HTTP preflight that expects an unauthenticated request to return 401, then checks an authenticated response's chat-completion shape. Six tests exercise it against a real loopback HTTP server with a synthetic response. No ngrok tunnel, Ollama process, LM Studio server or model inference was run for this experiment; the remote setup below is a documentation-based procedure to verify in your own environment.

Pick one authentication contract

For Ollama behind ngrok, a straightforward arrangement is Basic authentication enforced by an ngrok Traffic Policy. The backend remains bound to the local machine; the public endpoint requires a username and password before requests reach it. Ollama's FAQ documents local binding and proxy use, while ngrok's Ollama example covers the policy and host-header rewrite.

The header in that arrangement is Authorization: Basic …. An OpenAI-compatible SDK configured with a placeholder API key commonly sends a Bearer header instead. Bearer not-needed does not satisfy Basic authentication merely because the backend accepts an OpenAI-shaped request. Our fixture specifically rejects that header.

LM Studio has a separate option. Its authentication documentation describes API tokens in version 0.4.0 or newer; authentication must be enabled in server settings. Requests then use the documented Bearer token. The preflight's request_chat function can accept that header, but this experiment does not claim LM Studio integration coverage.

Do not assume a single Authorization header can simultaneously carry independent Basic and Bearer credentials. If the proxy and backend demand different schemes, define how the proxy authenticates the caller and supplies the backend credential. That is a separate configuration to test, including failure cases. For a first Ollama tunnel, the single Basic-auth boundary keeps the request contract easier to inspect.

Prepare the protected policy before opening the endpoint

The following policy is illustrative, based on ngrok's documentation. Replace the credential placeholder in a private local file and check the syntax against the agent version you install. These are not usable shared credentials:

YAML
on_http_request:
- actions:
- type: basic-auth
config:
realm: local-llm
credentials:
- REPLACE_USER:REPLACE_PASSWORD
enforce: true
- type: add-headers
config:
headers:
host: localhost

The authentication action comes before forwarding. The host rewrite is the part of ngrok's Ollama recipe that makes the upstream request use localhost. Keep the policy file out of source control and avoid putting credentials in a URL, where logs and history can retain them.

Only after the local model answers the expected route should you start the tunnel with this policy attached. The documented command shape is ngrok http 11434 --url https://YOUR_DOMAIN --traffic-policy-file ollama.yaml. The domain, available options and account capabilities must be checked for your installation. This note makes no free-plan, pricing or throughput promise.

The client base URL is the HTTPS origin, without a trailing /v1; the supplied function appends /v1/chat/completions. Select a model ID that actually exists in the backend. A proxy may be reachable while the chosen model is absent, unloaded or incompatible with the requested route.

The preflight makes failure part of success

The central check is deliberately small:

Python
def check(base,model,authorization):
denied,_=request_chat(base,model)
if denied!=401: raise RuntimeError(f'expected unauthenticated 401, got {denied}')
accepted,content=request_chat(base,model,authorization)
if accepted!=200: raise RuntimeError(f'authenticated request returned {accepted}')
return {'unauthenticated':denied,'authenticated':accepted,'response_shape':'chat content string'}

The first request has no authorization header. A 200 response fails the check rather than becoming a reassuring connectivity message. The second request must return 200 and a JSON response containing a string at choices[0].message.content. A wrong model ID, redirect or different response schema therefore cannot quietly pass as a working chat endpoint.

The client refuses public plaintext HTTP and embedded URL credentials. It follows no redirects, so an authentication-bearing request is not automatically sent onward to another location. The response has a size limit, a short timeout and a small requested output budget. These are preflight bounds, not a general streaming client: a cold model may require a larger timeout when you run your own check.

Download the client, tests and reproduction instructions. Python 3.11.5's standard library is sufficient. The local test command is:

Terminal
python3 -B -m unittest -v test_example.py

The suite starts a server on an ephemeral loopback port, then closes it. Missing credentials, incorrect Basic credentials and the placeholder Bearer header each return 401. Correct fixture credentials produce a synthetic chat response. Other tests cover a wrong model, refused redirects, invalid URLs and malformed credentials. Passing these tests verifies the client and fixture contract; it does not verify ngrok's enforcement.

Check the real boundary from outside the machine

For the actual tunnel, run the negative request from a separate client that has no local proxy bypass. Record the status without recording tokens or prompt bodies. Repeat with invalid credentials, then with the intended credentials and a harmless prompt. Also confirm the local server's listening address: an authenticated tunnel does not protect a second interface exposed directly on the network.

The script reads LLM_BASE_URL, LLM_MODEL, EDGE_USER and EDGE_PASSWORD from the environment when run directly. Supply the actual HTTPS origin and Basic-auth secret privately; the local unit tests need none of them. Stop the tunnel when the sharing session ends and verify that the public endpoint is no longer usable.

Authentication answers who can call the endpoint. It does not establish a safe concurrency limit, a rate limit, model quality or appropriate handling of sensitive prompts. Measure those concerns separately. For a short remote demonstration, the useful first milestone remains concrete: the anonymous request fails, the intended request succeeds, and both observations come from the public path you plan to share.

Found a mistake or tried a different approach?

Send Alex a note ↗